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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.43",
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.43",
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
- if (this.#active < limit) {
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
- return Promise.resolve();
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
- for (let index = 0; index < this.#waiters.length;) {
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
- PREVIEW_AUDIO_RECIPE,
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 probeAudioAsync(resolved, options.ffprobe ?? defaultFfprobe(), settingsFrom(options));
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
- state.outputPath = path.join(outputDirectory, `${state.key}.flac`);
448
- fs.mkdirSync(outputDirectory, { recursive: true });
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
- const metadata = await inspectAudio(outputPath);
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.flac`);
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 result = await ffmpegSlots.run(settings.concurrency.ffmpeg, () => runProcess(ffmpeg, [
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
- '-ar', '48000', '-c:a', 'flac', '-compression_level', '5',
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
- ], { timeoutMs: settings.timeoutMs.ffmpeg, maxBuffer: 16 * 1024 * 1024 }));
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('.tmp.flac')) {
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) return 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() || !entry.name.endsWith('.flac')) continue;
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.endsWith('.tmp.flac')) continue;
574
- const key = entry.name.slice(0, -'.flac'.length);
575
- if (kept.has(key)) continue;
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
- bytes += fs.statSync(target).size;
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
- bytes += fs.statSync(target).size;
597
- fs.rmSync(target, { force: true });
598
- removed += 1;
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
- assert.equal(probes, 2);
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
  }