akari-video 0.1.43 → 0.1.45
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/package.json +1 -1
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/media-bin/src/preview-audio-sidecar.mjs +368 -36
- package/vendor/packages/media-bin/test/audio-clip-fx.test.mjs +4 -2
- package/vendor/packages/media-bin/test/preview-audio-pcm-sidecar.test.mjs +153 -0
- package/vendor/packages/media-bin/test/preview-audio-sidecar-status.test.mjs +231 -0
- package/vendor/packages/media-bin/test/preview-audio-slot-order.test.mjs +374 -0
- package/vendor/packages/media-bin/test/speech-atempo.test.mjs +4 -1
- package/vendor/packages/overlay-runtime/README.md +3 -0
- package/vendor/packages/schemas/bin/validate-asset.mjs +38 -1
- package/vendor/packages/schemas/test/validate-glass-asset.test.mjs +56 -0
- package/vendor/skills/overlay-authoring/SKILL.md +1 -0
- package/vendor/skills/overlay-authoring/glass.md +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.45",
|
|
4
4
|
"description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -167,6 +167,7 @@
|
|
|
167
167
|
"skills/harvest-asset/SKILL.md",
|
|
168
168
|
"skills/manage-connections/SKILL.md",
|
|
169
169
|
"skills/overlay-authoring/3d.md",
|
|
170
|
+
"skills/overlay-authoring/glass.md",
|
|
170
171
|
"skills/overlay-authoring/motion.md",
|
|
171
172
|
"skills/overlay-authoring/SKILL.md",
|
|
172
173
|
"skills/overlay-authoring/table.md",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.45",
|
|
4
4
|
"description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -6,6 +6,26 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
import { resolveFfmpeg, resolveFfprobe } from './index.mjs';
|
|
7
7
|
|
|
8
8
|
export const PREVIEW_AUDIO_RECIPE = 'preview-audio-flac-v2';
|
|
9
|
+
export const PREVIEW_AUDIO_PCM_RECIPE = 'preview-audio-pcm-v1';
|
|
10
|
+
|
|
11
|
+
export function pcmWindowByteRange({ sampleRate, channels, bytesPerSample, frames }, startSec, endSec) {
|
|
12
|
+
if (!finitePositive(sampleRate) || !Number.isSafeInteger(channels) || channels <= 0
|
|
13
|
+
|| !Number.isSafeInteger(bytesPerSample) || bytesPerSample <= 0
|
|
14
|
+
|| !Number.isSafeInteger(frames) || frames < 0
|
|
15
|
+
|| !Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) return null;
|
|
16
|
+
const startFrame = Math.min(frames, Math.max(0, Math.floor(startSec * sampleRate)));
|
|
17
|
+
const endFrame = Math.min(frames, Math.max(0, Math.ceil(endSec * sampleRate)));
|
|
18
|
+
if (endFrame <= startFrame) return null;
|
|
19
|
+
const stride = channels * bytesPerSample;
|
|
20
|
+
return { startByte: startFrame * stride, endByte: endFrame * stride - 1,
|
|
21
|
+
startFrame, frameCount: endFrame - startFrame };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function audioFormat(options) {
|
|
25
|
+
const format = options?.format ?? 'flac';
|
|
26
|
+
if (format !== 'flac' && format !== 'pcm-s16le') throw new Error('format must be flac or pcm-s16le');
|
|
27
|
+
return format;
|
|
28
|
+
}
|
|
9
29
|
|
|
10
30
|
// Process-wide ceilings for the child processes this module starts. Both callers (the Theia
|
|
11
31
|
// backend that also serves media bytes over HTTP Range, and preview-server's /api/summary)
|
|
@@ -71,23 +91,58 @@ class Semaphore {
|
|
|
71
91
|
#waiters = [];
|
|
72
92
|
|
|
73
93
|
acquire(limit) {
|
|
74
|
-
|
|
94
|
+
return this.reserve(limit).acquire();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reserve(limit) {
|
|
98
|
+
const waiter = { limit, state: 'waiting', resolve: null, reject: null };
|
|
99
|
+
let pending;
|
|
100
|
+
this.#waiters.push(waiter);
|
|
101
|
+
return {
|
|
102
|
+
acquire: () => {
|
|
103
|
+
if (!pending) {
|
|
104
|
+
pending = new Promise((resolve, reject) => {
|
|
105
|
+
if (waiter.state === 'cancelled') {
|
|
106
|
+
reject(new Error('semaphore reservation cancelled'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
waiter.resolve = resolve;
|
|
110
|
+
waiter.reject = reject;
|
|
111
|
+
});
|
|
112
|
+
this.#drain();
|
|
113
|
+
}
|
|
114
|
+
return pending;
|
|
115
|
+
},
|
|
116
|
+
cancel: () => {
|
|
117
|
+
// Once acquired, the caller owns the slot and must release it normally.
|
|
118
|
+
if (waiter.state !== 'waiting') return;
|
|
119
|
+
waiter.state = 'cancelled';
|
|
120
|
+
this.#waiters.splice(this.#waiters.indexOf(waiter), 1);
|
|
121
|
+
waiter.reject?.(new Error('semaphore reservation cancelled'));
|
|
122
|
+
this.#drain();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#drain() {
|
|
128
|
+
if (this.#waiters.length) {
|
|
129
|
+
const waiter = this.#waiters[0];
|
|
130
|
+
// A probe reserves only its FIFO position, not an active ffmpeg slot.
|
|
131
|
+
// Later requests cannot pass it while it is still preparing to acquire.
|
|
132
|
+
if (!waiter.resolve || this.#active >= waiter.limit) return;
|
|
133
|
+
this.#waiters.shift();
|
|
75
134
|
this.#active += 1;
|
|
76
|
-
|
|
135
|
+
waiter.state = 'acquired';
|
|
136
|
+
waiter.resolve();
|
|
137
|
+
// Let a newly activated ticket attach its await continuation before waking
|
|
138
|
+
// followers whose acquire() promises already have continuations attached.
|
|
139
|
+
queueMicrotask(() => this.#drain());
|
|
77
140
|
}
|
|
78
|
-
return new Promise(resolve => this.#waiters.push({ limit, resolve }));
|
|
79
141
|
}
|
|
80
142
|
|
|
81
143
|
release() {
|
|
82
144
|
this.#active -= 1;
|
|
83
|
-
|
|
84
|
-
if (this.#active < this.#waiters[index].limit) {
|
|
85
|
-
this.#active += 1;
|
|
86
|
-
this.#waiters.splice(index, 1)[0].resolve();
|
|
87
|
-
} else {
|
|
88
|
-
index += 1;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
145
|
+
this.#drain();
|
|
91
146
|
}
|
|
92
147
|
|
|
93
148
|
async run(limit, task) {
|
|
@@ -100,6 +155,8 @@ class Semaphore {
|
|
|
100
155
|
}
|
|
101
156
|
}
|
|
102
157
|
|
|
158
|
+
export const __testing = { Semaphore };
|
|
159
|
+
|
|
103
160
|
const ffmpegSlots = new Semaphore();
|
|
104
161
|
const ffprobeSlots = new Semaphore();
|
|
105
162
|
|
|
@@ -316,6 +373,7 @@ export function previewAudioSidecarKey(options) {
|
|
|
316
373
|
padBeforeSec: options.padBeforeSec ?? 0,
|
|
317
374
|
padAfterSec: options.padAfterSec ?? 0,
|
|
318
375
|
filters: buildPreviewAudioFilterChain(options),
|
|
376
|
+
format: audioFormat(options),
|
|
319
377
|
});
|
|
320
378
|
}
|
|
321
379
|
|
|
@@ -330,7 +388,9 @@ function keyFor(sourcePath, stat, values) {
|
|
|
330
388
|
formatNumber(values.padBeforeSec),
|
|
331
389
|
formatNumber(values.padAfterSec),
|
|
332
390
|
...(values.filters ?? []),
|
|
333
|
-
|
|
391
|
+
// Keep the complete FLAC v2 hash input unchanged, including its final recipe token.
|
|
392
|
+
...(values.format === 'pcm-s16le'
|
|
393
|
+
? [PREVIEW_AUDIO_PCM_RECIPE, 'pcm-s16le', 24000, 1, 2] : [PREVIEW_AUDIO_RECIPE]),
|
|
334
394
|
].join('|')).digest('hex');
|
|
335
395
|
}
|
|
336
396
|
|
|
@@ -346,6 +406,7 @@ function probeArguments(filePath) {
|
|
|
346
406
|
function parseProbeOutput(stdout) {
|
|
347
407
|
const parsed = JSON.parse(stdout || '{}');
|
|
348
408
|
const stream = Array.isArray(parsed.streams) ? parsed.streams[0] : undefined;
|
|
409
|
+
if (!stream) throw new Error('ffprobe: no audio stream');
|
|
349
410
|
const durationSec = Number(parsed.format?.duration);
|
|
350
411
|
const sampleRate = Number(stream?.sample_rate);
|
|
351
412
|
const channels = Number(stream?.channels);
|
|
@@ -406,7 +467,9 @@ export async function probePreviewAudioSourceAsync(sourcePath, options = {}) {
|
|
|
406
467
|
const resolved = path.resolve(sourcePath);
|
|
407
468
|
const stat = await fs.promises.stat(resolved);
|
|
408
469
|
if (!stat.isFile()) throw new Error(`source is not a regular file: ${resolved}`);
|
|
409
|
-
const metadata = await
|
|
470
|
+
const metadata = await (typeof options.probeAudio === 'function'
|
|
471
|
+
? options.probeAudio(resolved, options.ffprobe ?? defaultFfprobe())
|
|
472
|
+
: probeAudioAsync(resolved, options.ffprobe ?? defaultFfprobe(), settingsFrom(options)));
|
|
410
473
|
return { ok: true, path: resolved, bytes: stat.size, ...metadata };
|
|
411
474
|
} catch (error) {
|
|
412
475
|
return probeFailure(sourcePath, error);
|
|
@@ -416,7 +479,7 @@ export async function probePreviewAudioSourceAsync(sourcePath, options = {}) {
|
|
|
416
479
|
// Validates the request and derives the cache key / output path. Deliberately synchronous:
|
|
417
480
|
// ensurePreviewAudioSidecar registers the in-flight promise right after this returns, so two
|
|
418
481
|
// concurrent requests for the same output path cannot both slip past the in-flight check.
|
|
419
|
-
function prepare(options, state) {
|
|
482
|
+
function prepare(options, state, createDirectory = true) {
|
|
420
483
|
if (!options || typeof options.sourcePath !== 'string' || !options.sourcePath) {
|
|
421
484
|
throw new Error('sourcePath is required');
|
|
422
485
|
}
|
|
@@ -428,6 +491,7 @@ function prepare(options, state) {
|
|
|
428
491
|
throw new Error('inSec, outSec, speed, and pads must describe a positive source range');
|
|
429
492
|
}
|
|
430
493
|
if (options.clipFx) validateAudioClipFx(options.clipFx);
|
|
494
|
+
const format = audioFormat(options);
|
|
431
495
|
if (typeof options.cacheDir !== 'string' || !options.cacheDir) {
|
|
432
496
|
throw new Error('cacheDir is required');
|
|
433
497
|
}
|
|
@@ -441,16 +505,20 @@ function prepare(options, state) {
|
|
|
441
505
|
padBeforeSec,
|
|
442
506
|
padAfterSec,
|
|
443
507
|
filters: buildPreviewAudioFilterChain({ ...options, padBeforeSec, padAfterSec }),
|
|
508
|
+
format,
|
|
444
509
|
};
|
|
445
510
|
state.key = keyFor(sourcePath, stat, values);
|
|
446
511
|
const outputDirectory = path.resolve(options.cacheDir, 'preview-audio');
|
|
447
|
-
|
|
448
|
-
|
|
512
|
+
const extension = format === 'pcm-s16le' ? 'pcm' : 'flac';
|
|
513
|
+
state.outputPath = path.join(outputDirectory, `${state.key}.${extension}`);
|
|
514
|
+
if (createDirectory) fs.mkdirSync(outputDirectory, { recursive: true });
|
|
449
515
|
return {
|
|
450
516
|
sourcePath,
|
|
451
517
|
outputDirectory,
|
|
452
518
|
outputPath: state.outputPath,
|
|
453
519
|
key: state.key,
|
|
520
|
+
format, extension,
|
|
521
|
+
recipe: format === 'pcm-s16le' ? PREVIEW_AUDIO_PCM_RECIPE : PREVIEW_AUDIO_RECIPE,
|
|
454
522
|
startSec: Math.max(0, options.inSec - padBeforeSec),
|
|
455
523
|
endSec: options.outSec + padAfterSec,
|
|
456
524
|
speed: options.speed,
|
|
@@ -472,13 +540,20 @@ async function generate(prepared, options) {
|
|
|
472
540
|
const settings = settingsFrom(options);
|
|
473
541
|
const ffprobeOf = () => options.ffprobe ?? defaultFfprobe();
|
|
474
542
|
// テスト・呼び出し側の注入シーム(T4 由来): probeAudio があれば同期関数として尊重する。
|
|
475
|
-
const inspectAudio = async (p) => (typeof options.probeAudio === 'function'
|
|
543
|
+
const inspectAudio = async (p) => prepared.format === 'pcm-s16le' ? inspectPcm(p) : (typeof options.probeAudio === 'function'
|
|
476
544
|
? options.probeAudio(p, ffprobeOf())
|
|
477
545
|
: probeAudioAsync(p, ffprobeOf(), settings));
|
|
478
546
|
const fromExisting = async () => {
|
|
479
|
-
|
|
547
|
+
options.slot?.cancel();
|
|
548
|
+
let metadata = readMetadata(prepared);
|
|
549
|
+
if (!metadata) {
|
|
550
|
+
metadata = await inspectAudio(outputPath);
|
|
551
|
+
writeMetadata(prepared, options, metadata, prepared.format === 'flac');
|
|
552
|
+
}
|
|
480
553
|
return {
|
|
481
554
|
ok: true, skipped: true, path: outputPath, key,
|
|
555
|
+
format: prepared.format,
|
|
556
|
+
...(prepared.format === 'pcm-s16le' ? { frames: metadata.frames, bytesPerSample: 2 } : {}),
|
|
482
557
|
durationSec: metadata.durationSec,
|
|
483
558
|
sampleRate: metadata.sampleRate,
|
|
484
559
|
channels: metadata.channels,
|
|
@@ -492,34 +567,51 @@ async function generate(prepared, options) {
|
|
|
492
567
|
try {
|
|
493
568
|
if (fs.existsSync(outputPath)) return fromExisting();
|
|
494
569
|
const temporary = path.join(outputDirectory,
|
|
495
|
-
`.${path.basename(outputPath)}.${process.pid}.${Date.now()}.tmp.
|
|
570
|
+
`.${path.basename(outputPath)}.${process.pid}.${Date.now()}.tmp.${prepared.extension}`);
|
|
496
571
|
try {
|
|
497
572
|
const filters = prepared.filters ?? [
|
|
498
573
|
'asetpts=PTS-STARTPTS',
|
|
499
574
|
...buildAtempoChain(prepared.speed).map(factor => `atempo=${formatNumber(factor)}`),
|
|
500
575
|
];
|
|
501
576
|
const ffmpeg = options.ffmpeg ?? resolveFfmpeg();
|
|
502
|
-
const
|
|
577
|
+
const args = [
|
|
503
578
|
'-hide_banner', '-nostdin', '-loglevel', 'error',
|
|
504
579
|
...(!prepared.clipFx ? ['-ss', formatNumber(prepared.startSec), '-to', formatNumber(prepared.endSec)] : []),
|
|
505
580
|
'-i', sourcePath,
|
|
506
581
|
'-map', '0:a:0', '-vn',
|
|
507
582
|
'-af', filters.join(','),
|
|
508
|
-
|
|
583
|
+
...(prepared.format === 'pcm-s16le'
|
|
584
|
+
? ['-ar', '24000', '-ac', '1', '-f', 's16le', '-c:a', 'pcm_s16le']
|
|
585
|
+
: ['-ar', '48000', '-c:a', 'flac', '-compression_level', '5']),
|
|
509
586
|
'-y', temporary,
|
|
510
|
-
]
|
|
587
|
+
];
|
|
588
|
+
const processOptions = { timeoutMs: settings.timeoutMs.ffmpeg, maxBuffer: 16 * 1024 * 1024 };
|
|
589
|
+
let result;
|
|
590
|
+
if (options.slot) {
|
|
591
|
+
await options.slot.acquire();
|
|
592
|
+
try {
|
|
593
|
+
result = await runProcess(ffmpeg, args, processOptions);
|
|
594
|
+
} finally {
|
|
595
|
+
ffmpegSlots.release();
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
result = await ffmpegSlots.run(settings.concurrency.ffmpeg, () => runProcess(ffmpeg, args, processOptions));
|
|
599
|
+
}
|
|
511
600
|
if (result.error || result.status !== 0) {
|
|
512
601
|
throw processFailure(result, 'ffmpeg failed to create the preview audio sidecar');
|
|
513
602
|
}
|
|
514
603
|
const outputStat = fs.statSync(temporary);
|
|
515
|
-
if (!outputStat.isFile() || outputStat.size <= 42) {
|
|
604
|
+
if (!outputStat.isFile() || outputStat.size <= (prepared.format === 'pcm-s16le' ? 0 : 42)) {
|
|
516
605
|
throw new Error('ffmpeg created an empty preview audio sidecar');
|
|
517
606
|
}
|
|
518
607
|
const metadata = await inspectAudio(temporary);
|
|
519
|
-
if (metadata.sampleRate !== 48000) throw new Error('preview audio sidecar is not 48 kHz');
|
|
608
|
+
if (prepared.format === 'flac' && metadata.sampleRate !== 48000) throw new Error('preview audio sidecar is not 48 kHz');
|
|
520
609
|
fs.renameSync(temporary, outputPath);
|
|
610
|
+
writeMetadata(prepared, options, metadata);
|
|
521
611
|
return {
|
|
522
612
|
ok: true, skipped: false, path: outputPath, key,
|
|
613
|
+
format: prepared.format,
|
|
614
|
+
...(prepared.format === 'pcm-s16le' ? { frames: metadata.frames, bytesPerSample: 2 } : {}),
|
|
523
615
|
durationSec: metadata.durationSec,
|
|
524
616
|
sampleRate: metadata.sampleRate,
|
|
525
617
|
channels: metadata.channels,
|
|
@@ -527,7 +619,7 @@ async function generate(prepared, options) {
|
|
|
527
619
|
};
|
|
528
620
|
} finally {
|
|
529
621
|
for (const name of fs.readdirSync(outputDirectory)) {
|
|
530
|
-
if (name.startsWith(`.${path.basename(outputPath)}.${process.pid}.`) && name.endsWith(
|
|
622
|
+
if (name.startsWith(`.${path.basename(outputPath)}.${process.pid}.`) && name.endsWith(`.tmp.${prepared.extension}`)) {
|
|
531
623
|
fs.rmSync(path.join(outputDirectory, name), { force: true });
|
|
532
624
|
}
|
|
533
625
|
}
|
|
@@ -541,6 +633,223 @@ async function generate(prepared, options) {
|
|
|
541
633
|
// sidecar share one promise (and one ffmpeg). The entry is dropped as soon as it settles, so a
|
|
542
634
|
// later request finds the finished file and takes the "already exists → reuse" path.
|
|
543
635
|
const generating = new Map();
|
|
636
|
+
const requested = new Map();
|
|
637
|
+
const probing = new Map();
|
|
638
|
+
const listeners = new Set();
|
|
639
|
+
const FAILURE_RETRY_MS = 60000;
|
|
640
|
+
|
|
641
|
+
function readCacheJson(filePath) {
|
|
642
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function writeCacheJson(filePath, value) {
|
|
646
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
647
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp.json`;
|
|
648
|
+
try {
|
|
649
|
+
fs.writeFileSync(temporary, JSON.stringify(value), 'utf8');
|
|
650
|
+
fs.renameSync(temporary, filePath);
|
|
651
|
+
} finally {
|
|
652
|
+
fs.rmSync(temporary, { force: true });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function metadataPath(prepared) {
|
|
657
|
+
return path.join(prepared.outputDirectory, `${prepared.key}.json`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function readMetadata(prepared) {
|
|
661
|
+
const value = readCacheJson(metadataPath(prepared));
|
|
662
|
+
const format = value?.format ?? 'flac';
|
|
663
|
+
return value?.recipe === prepared.recipe && value.key === prepared.key && format === prepared.format
|
|
664
|
+
&& finitePositive(value.durationSec) && finitePositive(value.sampleRate)
|
|
665
|
+
&& Number.isInteger(value.channels) && value.channels > 0 && finiteNonNegative(value.bytes)
|
|
666
|
+
&& (format !== 'pcm-s16le' || (value.sampleRate === 24000 && value.channels === 1
|
|
667
|
+
&& Number.isSafeInteger(value.frames) && value.frames > 0 && value.bytesPerSample === 2
|
|
668
|
+
&& value.bytes === value.frames * 2 && value.durationSec === value.frames / 24000))
|
|
669
|
+
? { ...value, format } : null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function inspectPcm(filePath) {
|
|
673
|
+
const stat = fs.statSync(filePath);
|
|
674
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size % 2 !== 0) {
|
|
675
|
+
throw new Error('preview PCM sidecar must contain complete nonempty s16le frames');
|
|
676
|
+
}
|
|
677
|
+
const frames = stat.size / 2;
|
|
678
|
+
return { sampleRate: 24000, channels: 1, frames, bytesPerSample: 2, durationSec: frames / 24000 };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function writeMetadata(prepared, options, metadata, legacyFlac = false) {
|
|
682
|
+
writeCacheJson(metadataPath(prepared), {
|
|
683
|
+
recipe: prepared.recipe, key: prepared.key,
|
|
684
|
+
...(!legacyFlac ? { format: prepared.format } : {}),
|
|
685
|
+
...(prepared.format === 'pcm-s16le' ? { frames: metadata.frames, bytesPerSample: 2 } : {}),
|
|
686
|
+
durationSec: metadata.durationSec, sampleRate: metadata.sampleRate, channels: metadata.channels,
|
|
687
|
+
bytes: fs.statSync(prepared.outputPath).size,
|
|
688
|
+
inSec: options.inSec, outSec: options.outSec, speed: options.speed,
|
|
689
|
+
padBeforeSec: options.padBeforeSec ?? 0, padAfterSec: options.padAfterSec ?? 0,
|
|
690
|
+
createdAt: Date.now(),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function classifyPreviewAudioFailure(reason) {
|
|
695
|
+
// ffmpeg map errors (including builds which print an empty map), muxer errors,
|
|
696
|
+
// and our ffprobe empty-stream result all mean this fingerprint has no audio.
|
|
697
|
+
return /Stream map ['"][^'"]*['"] matches no streams|does not contain any stream|no audio stream/iu
|
|
698
|
+
.test(String(reason)) ? 'no-audio' : 'transient';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function retryRemaining(record) {
|
|
702
|
+
const createdAt = typeof record?.createdAt === 'number' ? record.createdAt : Date.parse(record?.createdAt);
|
|
703
|
+
return Math.max(0, createdAt + (record?.retryAfterMs ?? FAILURE_RETRY_MS) - Date.now()) || 0;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Validate even duration-less requests synchronously, but obtain their real endpoint only
|
|
707
|
+
// from a fingerprint-bound probe cache. No resolver or child process runs on this path.
|
|
708
|
+
function requestOptions(options) {
|
|
709
|
+
if (options?.outSec !== undefined) {
|
|
710
|
+
if (finitePositive(options.decodedBytesThreshold)) {
|
|
711
|
+
prepare(options, {}, false);
|
|
712
|
+
const duration = options.outSec - options.inSec + (options.padBeforeSec ?? 0) + (options.padAfterSec ?? 0);
|
|
713
|
+
const heavy = duration * 48000 * 2 * 4 > options.decodedBytesThreshold;
|
|
714
|
+
if (!heavy && !hasAudioClipFx(options.clipFx)) return { status: { state: 'not-needed', key: null } };
|
|
715
|
+
return { options: { ...options, decodedBytesThreshold: undefined, format: heavy ? 'pcm-s16le' : 'flac' } };
|
|
716
|
+
}
|
|
717
|
+
return { options };
|
|
718
|
+
}
|
|
719
|
+
const validated = prepare({ ...options, outSec: (options?.inSec ?? 0) + 1 }, {}, false);
|
|
720
|
+
const stat = fs.statSync(validated.sourcePath);
|
|
721
|
+
const fingerprint = crypto.createHash('sha1')
|
|
722
|
+
.update([validated.sourcePath, stat.size, stat.mtimeMs].join('|')).digest('hex');
|
|
723
|
+
const probePath = path.join(validated.outputDirectory, `probe-${fingerprint}.json`);
|
|
724
|
+
const cached = readCacheJson(probePath);
|
|
725
|
+
if (cached?.error && (cached.error.class === 'no-audio' || retryRemaining(cached.error) > 0)) {
|
|
726
|
+
return { probePath, status: {
|
|
727
|
+
state: cached.error.class === 'no-audio' ? 'no-audio' : 'failed', key: null,
|
|
728
|
+
reason: cached.error.reason, probe: { fingerprint },
|
|
729
|
+
...(cached.error.class === 'transient' ? { retryAfterMs: retryRemaining(cached.error) } : {}),
|
|
730
|
+
} };
|
|
731
|
+
}
|
|
732
|
+
if (finitePositive(cached?.durationSec)) {
|
|
733
|
+
const resolved = requestOptions({ ...options, outSec: cached.durationSec });
|
|
734
|
+
return { ...resolved, probePath, probe: { fingerprint },
|
|
735
|
+
...(resolved.status ? { status: { ...resolved.status, probe: { fingerprint } } } : {}) };
|
|
736
|
+
}
|
|
737
|
+
return { probePath, status: { state: 'queued', key: null, probe: { fingerprint, pending: true } } };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function previewAudioSidecarStatus(options) {
|
|
741
|
+
try {
|
|
742
|
+
const resolved = requestOptions(options);
|
|
743
|
+
if (resolved.status) return resolved.status;
|
|
744
|
+
if (resolved.probe) return { ...previewAudioSidecarStatus(resolved.options), probe: resolved.probe };
|
|
745
|
+
const prepared = prepare(resolved.options, {}, false);
|
|
746
|
+
const { key, outputPath, outputDirectory } = prepared;
|
|
747
|
+
if (requested.has(outputPath) || generating.has(outputPath)) return { state: 'generating', key };
|
|
748
|
+
const metadata = readMetadata(prepared);
|
|
749
|
+
if (metadata && fs.existsSync(outputPath)) {
|
|
750
|
+
const { durationSec, sampleRate, channels, bytes, format, frames, bytesPerSample } = metadata;
|
|
751
|
+
return { state: 'ready', key, path: outputPath, durationSec, sampleRate, channels, bytes, format,
|
|
752
|
+
...(format === 'pcm-s16le' ? { frames, bytesPerSample } : {}) };
|
|
753
|
+
}
|
|
754
|
+
const noAudio = readCacheJson(path.join(outputDirectory, `${key}.no-audio.json`));
|
|
755
|
+
if (noAudio?.key === key) return { state: 'no-audio', key, reason: noAudio.reason };
|
|
756
|
+
const failed = readCacheJson(path.join(outputDirectory, `${key}.failed.json`));
|
|
757
|
+
if (failed?.key === key && retryRemaining(failed) > 0) {
|
|
758
|
+
return { state: 'failed', key, reason: failed.reason, retryAfterMs: retryRemaining(failed) };
|
|
759
|
+
}
|
|
760
|
+
return { state: fs.existsSync(outputPath) ? 'legacy' : 'missing', key };
|
|
761
|
+
} catch (error) {
|
|
762
|
+
return { state: 'invalid', reason: summarize(error?.message, 'invalid preview audio request') };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function subscribePreviewAudioSidecarEvents(listener) {
|
|
767
|
+
listeners.add(listener);
|
|
768
|
+
return () => listeners.delete(listener);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function emitSidecarEvent(event) {
|
|
772
|
+
for (const listener of [...listeners]) {
|
|
773
|
+
try { listener(event); } catch (error) { console.warn('[preview-audio] listener failed', error); }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function requestPreviewAudioSidecar(options) {
|
|
778
|
+
// Internal slot ownership transfers only when a background job was queued.
|
|
779
|
+
// All synchronous terminal states (including in-flight joins) discard the ticket.
|
|
780
|
+
let queued = false;
|
|
781
|
+
try {
|
|
782
|
+
const result = requestSidecar(options);
|
|
783
|
+
queued = result.state === 'queued';
|
|
784
|
+
return result;
|
|
785
|
+
} finally {
|
|
786
|
+
if (!queued) options?.slot?.cancel();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function requestSidecar(options) {
|
|
791
|
+
const status = previewAudioSidecarStatus(options);
|
|
792
|
+
if (status.state === 'invalid') return status;
|
|
793
|
+
const resolved = requestOptions(options);
|
|
794
|
+
if (resolved.status) {
|
|
795
|
+
if (!status.probe?.pending || probing.has(resolved.probePath)) return status;
|
|
796
|
+
// Start after returning the declaration. The map is populated before any work runs.
|
|
797
|
+
const pending = new Promise(resolve => setImmediate(resolve)).then(async () => {
|
|
798
|
+
const slot = ffmpegSlots.reserve(settingsFrom(options).concurrency.ffmpeg);
|
|
799
|
+
let handedOff = false;
|
|
800
|
+
try {
|
|
801
|
+
const probe = await probePreviewAudioSourceAsync(options.sourcePath, options);
|
|
802
|
+
if (probe.ok) {
|
|
803
|
+
writeCacheJson(resolved.probePath, probe);
|
|
804
|
+
const result = requestPreviewAudioSidecar({ ...options, outSec: probe.durationSec, slot });
|
|
805
|
+
handedOff = true;
|
|
806
|
+
if (['ready', 'no-audio', 'failed', 'not-needed'].includes(result.state)) {
|
|
807
|
+
emitSidecarEvent({ key: result.key, state: result.state, sourcePath: path.resolve(options.sourcePath),
|
|
808
|
+
...(result.state === 'ready'
|
|
809
|
+
? { path: result.path, durationSec: result.durationSec } : { reason: result.reason }) });
|
|
810
|
+
}
|
|
811
|
+
} else {
|
|
812
|
+
const failureClass = classifyPreviewAudioFailure(probe.reason);
|
|
813
|
+
writeCacheJson(resolved.probePath, {
|
|
814
|
+
error: { class: failureClass, reason: probe.reason, createdAt: Date.now() },
|
|
815
|
+
});
|
|
816
|
+
// A failed probe settles the overall request; successful probes have no event.
|
|
817
|
+
emitSidecarEvent({ key: null, state: failureClass === 'no-audio' ? 'no-audio' : 'failed',
|
|
818
|
+
reason: probe.reason, sourcePath: path.resolve(options.sourcePath) });
|
|
819
|
+
}
|
|
820
|
+
} finally {
|
|
821
|
+
if (!handedOff) slot.cancel();
|
|
822
|
+
}
|
|
823
|
+
}).catch(error => console.warn('[preview-audio] probe cache failed', error))
|
|
824
|
+
.finally(() => probing.delete(resolved.probePath));
|
|
825
|
+
probing.set(resolved.probePath, pending);
|
|
826
|
+
return status;
|
|
827
|
+
}
|
|
828
|
+
if (status.state !== 'missing' && status.state !== 'legacy') return status;
|
|
829
|
+
const prepared = prepare(resolved.options, {}, false);
|
|
830
|
+
const pending = new Promise(resolve => setImmediate(resolve)).then(async () => {
|
|
831
|
+
fs.rmSync(path.join(prepared.outputDirectory, `${prepared.key}.failed.json`), { force: true });
|
|
832
|
+
const result = await ensurePreviewAudioSidecar(resolved.options);
|
|
833
|
+
let state = 'ready';
|
|
834
|
+
if (!result.ok) {
|
|
835
|
+
state = classifyPreviewAudioFailure(result.reason) === 'no-audio' ? 'no-audio' : 'failed';
|
|
836
|
+
writeCacheJson(path.join(prepared.outputDirectory, `${prepared.key}.${state}.json`), {
|
|
837
|
+
key: prepared.key, reason: result.reason, createdAt: Date.now(),
|
|
838
|
+
...(state === 'failed' ? { retryAfterMs: FAILURE_RETRY_MS } : {}),
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
return { key: prepared.key, state, sourcePath: prepared.sourcePath,
|
|
842
|
+
...(result.ok ? { path: result.path, durationSec: result.durationSec } : { reason: result.reason }) };
|
|
843
|
+
}).catch(error => ({ key: prepared.key, state: 'failed', sourcePath: prepared.sourcePath,
|
|
844
|
+
reason: summarize(error?.message, 'preview audio cache failed') }))
|
|
845
|
+
.finally(() => options.slot?.cancel())
|
|
846
|
+
.then(event => {
|
|
847
|
+
requested.delete(prepared.outputPath);
|
|
848
|
+
emitSidecarEvent(event);
|
|
849
|
+
});
|
|
850
|
+
requested.set(prepared.outputPath, pending);
|
|
851
|
+
return { state: 'queued', key: prepared.key, ...(status.probe ? { probe: status.probe } : {}) };
|
|
852
|
+
}
|
|
544
853
|
|
|
545
854
|
export function ensurePreviewAudioSidecar(options) {
|
|
546
855
|
const state = { outputPath: null, key: null };
|
|
@@ -548,36 +857,53 @@ export function ensurePreviewAudioSidecar(options) {
|
|
|
548
857
|
try {
|
|
549
858
|
prepared = prepare(options, state);
|
|
550
859
|
} catch (error) {
|
|
860
|
+
options?.slot?.cancel();
|
|
551
861
|
return Promise.resolve(failure(state.outputPath, state.key, error));
|
|
552
862
|
}
|
|
553
863
|
const joined = generating.get(prepared.outputPath);
|
|
554
|
-
if (joined)
|
|
864
|
+
if (joined) {
|
|
865
|
+
options.slot?.cancel();
|
|
866
|
+
return joined;
|
|
867
|
+
}
|
|
555
868
|
const pending = generate(prepared, options)
|
|
869
|
+
.finally(() => options.slot?.cancel())
|
|
556
870
|
.catch(error => failure(prepared.outputPath, prepared.key, error));
|
|
557
871
|
generating.set(prepared.outputPath, pending);
|
|
558
872
|
void pending.finally(() => generating.delete(prepared.outputPath));
|
|
559
873
|
return pending;
|
|
560
874
|
}
|
|
561
875
|
|
|
562
|
-
export function sweepPreviewAudioSidecars({ cacheDir, keepKeys }) {
|
|
563
|
-
const kept = new Set(Array.from(keepKeys ?? [], value => String(value).replace(/\.flac$/u, '')));
|
|
876
|
+
export function sweepPreviewAudioSidecars({ cacheDir, keepKeys, minAgeMs = 0, keepProbes }) {
|
|
877
|
+
const kept = new Set(Array.from(keepKeys ?? [], value => String(value).replace(/\.(?:flac|pcm)$/u, '')));
|
|
878
|
+
const keptProbes = new Set(Array.from(keepProbes ?? [], value =>
|
|
879
|
+
String(value).replace(/^probe-/u, '').replace(/\.json$/u, '')));
|
|
564
880
|
const outputDirectory = path.resolve(cacheDir, 'preview-audio');
|
|
565
881
|
let removed = 0;
|
|
566
882
|
let bytes = 0;
|
|
567
883
|
try {
|
|
568
884
|
for (const entry of fs.readdirSync(outputDirectory, { withFileTypes: true })) {
|
|
569
|
-
if (!entry.isFile()
|
|
885
|
+
if (!entry.isFile()) continue;
|
|
570
886
|
// 生成途中の一時ファイル(`.<key>.flac.<pid>.<ms>.tmp.flac`)も `.flac` で終わる。
|
|
571
887
|
// ffmpeg が開いている最中に rm すると Windows は EPERM を投げ、以前はそれが
|
|
572
888
|
// /api/summary まで抜けてサーバごと落ちた(実機 2026-09-05 14:25)。掃除の対象外にする。
|
|
573
|
-
if (entry.name.startsWith('.') || entry.name
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
889
|
+
if (entry.name.startsWith('.') || /\.tmp\./u.test(entry.name)) continue;
|
|
890
|
+
const match = /^(.*?)(?:\.flac|\.pcm|\.no-audio\.json|\.failed\.json|\.json)$/u.exec(entry.name);
|
|
891
|
+
if (!match) continue;
|
|
892
|
+
const key = match[1];
|
|
893
|
+
const probingFile = key.startsWith('probe-');
|
|
894
|
+
const outputPath = path.join(outputDirectory, `${key}.flac`);
|
|
895
|
+
const pcmPath = path.join(outputDirectory, `${key}.pcm`);
|
|
896
|
+
if ((!probingFile && kept.has(key)) || generating.has(outputPath)
|
|
897
|
+
|| generating.has(pcmPath) || requested.has(pcmPath)
|
|
898
|
+
|| (probingFile && keptProbes.has(key.slice('probe-'.length)))
|
|
899
|
+
|| requested.has(outputPath) || probing.has(path.join(outputDirectory, entry.name))) continue;
|
|
576
900
|
const target = path.join(outputDirectory, entry.name);
|
|
577
901
|
// 1 本消せないだけで掃除全体(ましてサーバ)を止めない。ロック中・消えた直後は次回に回す。
|
|
578
902
|
try {
|
|
579
|
-
|
|
903
|
+
const stat = fs.statSync(target);
|
|
904
|
+
if (minAgeMs > 0 && Date.now() - stat.mtimeMs < minAgeMs) continue;
|
|
580
905
|
fs.rmSync(target, { force: true });
|
|
906
|
+
bytes += stat.size;
|
|
581
907
|
removed += 1;
|
|
582
908
|
} catch (error) {
|
|
583
909
|
if (!['ENOENT', 'EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) throw error;
|
|
@@ -593,9 +919,15 @@ export function sweepPreviewAudioSidecars({ cacheDir, keepKeys }) {
|
|
|
593
919
|
for (const entry of fs.readdirSync(legacyDirectory, { withFileTypes: true })) {
|
|
594
920
|
if (!entry.isFile() || !entry.name.endsWith('.wav')) continue;
|
|
595
921
|
const target = path.join(legacyDirectory, entry.name);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
922
|
+
try {
|
|
923
|
+
const stat = fs.statSync(target);
|
|
924
|
+
if (minAgeMs > 0 && Date.now() - stat.mtimeMs < minAgeMs) continue;
|
|
925
|
+
fs.rmSync(target, { force: true });
|
|
926
|
+
bytes += stat.size;
|
|
927
|
+
removed += 1;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (!['ENOENT', 'EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) throw error;
|
|
930
|
+
}
|
|
599
931
|
}
|
|
600
932
|
if (fs.readdirSync(legacyDirectory).length === 0) fs.rmdirSync(legacyDirectory);
|
|
601
933
|
} catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
@@ -90,7 +90,9 @@ test('identical input reuses the cached sidecar without regenerating it', async
|
|
|
90
90
|
assert.equal(first.skipped, true);
|
|
91
91
|
assert.equal(second.skipped, true);
|
|
92
92
|
assert.equal(first.path, second.path);
|
|
93
|
-
|
|
93
|
+
// The first probe persists metadata as JSON, so cache reuse needs only one probe.
|
|
94
|
+
assert.equal(probes, 1);
|
|
95
|
+
assert.equal(existsSync(join(outputDirectory, `${key}.json`)), true);
|
|
94
96
|
} finally {
|
|
95
97
|
rmSync(root, { recursive: true, force: true });
|
|
96
98
|
}
|