akari-video 0.1.52 → 0.1.54

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.52",
3
+ "version": "0.1.54",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
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": [
@@ -7,6 +7,8 @@
7
7
  - `akari media filmstrip <target>` — 素材全体の流れをコンタクトシートにする。
8
8
  - `akari media waveform <target>` — 波形、無音区間、ラウドネスを調べる。
9
9
  - `akari media transcribe <target>` — ローカル既定の時刻付き文字起こしを返す。
10
+ - `akari media transcribe-diff <target> [--engines a,b,c]` — 最新のエンジン別文字起こしを文字単位で比較し、`diff.json` と JSON 要約を返す。句読点だけの差は除外し、1 エンジンなら比較を省略する。
11
+ - `akari media transcribe-cuts <target> [--basis b] [--silence-min 1.5] [--silence-break 3.0] [--silence-keep 0.5]` — フィラー・言い直し・無音・未認識を `cuts.json` に候補として保存し、JSON 要約を返す。既存の採否を引き継ぎ、手直しした字幕に重なる候補は既定 OFF にする。
10
12
  - `akari media audio-level <projectDir>` — `gain_db` 未指定の音声素材を測り、役割別の提案値を表にする。`--write` で `edit.json` へ数値を保存し、`--json` で JSON 配列を返す。
11
13
 
12
14
  ## 音声素材の挿入レベル
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
3
  import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
@@ -57,11 +57,13 @@ test('resolveLibraryFallback accepts only a declared regular file inside the lib
57
57
  await writeFile(file, 'frame', 'utf8');
58
58
  const references = [{ id: 'card', category: 'still' }];
59
59
 
60
+ // resolveLibraryFallback は symlink 脱出を塞ぐため realpath を返す。macOS の os.tmpdir() は
61
+ // /var/folders/... (/private/var/... への symlink)なので、期待値も同じ正規化を通す。
60
62
  assert.equal(resolveLibraryFallback({
61
63
  declaredPath: 'assets/still/card/nested/frame.png',
62
64
  references,
63
65
  akariAssetsDir: assetsDir,
64
- }), file);
66
+ }), realpathSync(file));
65
67
  assert.equal(resolveLibraryFallback({
66
68
  declaredPath: 'assets/still/card/../../outside.png',
67
69
  references,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 文字起こしパネル(1 行 = 1 字幕の Monaco 編集)で **行数が変わる編集** をしたときの
3
+ * 「行の差分 → 字幕操作列」への写像と、その操作に伴う時刻割り付け・id 採番。
4
+ * task 2026-09-08-caption-line-ops(issue #66)の指示 (A) / (B) / (D)。
5
+ *
6
+ * ここに置くのは決定論的な純関数だけ(DOM・時刻・乱数に依存しない = 同じ入力なら同じ操作列)。
7
+ * 実際の captions.json への書き戻しは呼び出し側が既存の外科手術関数
8
+ * (replaceCaptionLine / insertCaptionLine / removeCaptionLine / setCaptionTimingLine)へ落とす。
9
+ */
10
+ /** 分割後・追加後の 1 行が最低限持つ表示尺(秒)。これを割るときは操作を拒否する。 */
11
+ export declare const CAPTION_LINE_MIN_SECONDS = 0.2;
12
+ /**
13
+ * 末尾(次の字幕が無い場所)へ字幕を足すときの既定尺(秒)。
14
+ * edit-lint の captions.short-duration(1.0 秒未満で warning)に自分から当たらない値を取る。
15
+ */
16
+ export declare const CAPTION_LINE_DEFAULT_SECONDS = 1;
17
+ export type CaptionLineOp = {
18
+ readonly kind: 'replace';
19
+ readonly id: string;
20
+ readonly text: string;
21
+ } | {
22
+ readonly kind: 'split';
23
+ readonly id: string;
24
+ readonly texts: string[];
25
+ } | {
26
+ readonly kind: 'merge';
27
+ readonly ids: string[];
28
+ readonly text: string;
29
+ } | {
30
+ readonly kind: 'remove';
31
+ readonly id: string;
32
+ } | {
33
+ readonly kind: 'insert';
34
+ readonly afterId: string | undefined;
35
+ readonly text: string;
36
+ };
37
+ export interface CaptionLineSpan {
38
+ start: number;
39
+ end: number;
40
+ }
41
+ /**
42
+ * baseline(保存済みの表示行)と next(編集後の表示行)の差分を字幕操作列へ写す。
43
+ *
44
+ * アルゴリズムは先頭・末尾から一致行を削る古典的な差分(LCS は使わない)。中央に残った
45
+ * 「旧 n 行 → 新 m 行」の塊を次の順に落とす:
46
+ * - n === 1 && m > 1 → split
47
+ * - n > 1 && m === 1 → merge
48
+ * - それ以外 → min(n, m) 組を replace(テキストが変わった行だけ)+ 余りを remove / insert
49
+ *
50
+ * 中央の塊は 1 つしか残らないので、split と remove のような別種の操作が 1 回の呼び出しで
51
+ * 同時に出ることはない(連続した編集はそのたびに呼ばれる)。
52
+ */
53
+ export declare function diffCaptionLines(baseline: readonly string[], next: readonly string[], captions: readonly {
54
+ id: string;
55
+ }[]): CaptionLineOp[];
56
+ /**
57
+ * split の時刻割り付け(指示 B-2)。元の [start, end] を分割後テキストの **文字数比** で按分し、
58
+ * 境界は元の区間内に収める。words があっても使わない(words の無い字幕と同じ挙動を優先する)。
59
+ * どれか 1 つでも minSeconds を持てないときは undefined を返す(= 分割を拒否)。
60
+ */
61
+ export declare function planSplitSpans(start: number, end: number, texts: readonly string[], minSeconds?: number): CaptionLineSpan[] | undefined;
62
+ /**
63
+ * insert の時刻割り付け(指示 B-5)。直前行の end から次行の start までの隙間へ count 行を置く。
64
+ * 隙間が count × minSeconds に足りないときは undefined を返す(= 追加を拒否)。
65
+ * 次行が無い(末尾への追加)ときは隙間の上限が無いので CAPTION_LINE_DEFAULT_SECONDS ずつ並べる。
66
+ */
67
+ export declare function planInsertSpans(previousEnd: number, nextStart: number | undefined, count: number, minSeconds?: number): CaptionLineSpan[] | undefined;
68
+ /**
69
+ * `c-0001`, `c-0002`, ... のうち既存 id と衝突しないものを順に返す採番器
70
+ * (timeline-material-insert.ts の nextSourceId と同型: 使用済み集合 + 衝突する限り繰り上げ)。
71
+ */
72
+ export declare function createCaptionIdAllocator(existingIds: Iterable<string>): () => string;
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ /**
3
+ * 文字起こしパネル(1 行 = 1 字幕の Monaco 編集)で **行数が変わる編集** をしたときの
4
+ * 「行の差分 → 字幕操作列」への写像と、その操作に伴う時刻割り付け・id 採番。
5
+ * task 2026-09-08-caption-line-ops(issue #66)の指示 (A) / (B) / (D)。
6
+ *
7
+ * ここに置くのは決定論的な純関数だけ(DOM・時刻・乱数に依存しない = 同じ入力なら同じ操作列)。
8
+ * 実際の captions.json への書き戻しは呼び出し側が既存の外科手術関数
9
+ * (replaceCaptionLine / insertCaptionLine / removeCaptionLine / setCaptionTimingLine)へ落とす。
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.CAPTION_LINE_DEFAULT_SECONDS = exports.CAPTION_LINE_MIN_SECONDS = void 0;
13
+ exports.diffCaptionLines = diffCaptionLines;
14
+ exports.planSplitSpans = planSplitSpans;
15
+ exports.planInsertSpans = planInsertSpans;
16
+ exports.createCaptionIdAllocator = createCaptionIdAllocator;
17
+ /** 分割後・追加後の 1 行が最低限持つ表示尺(秒)。これを割るときは操作を拒否する。 */
18
+ exports.CAPTION_LINE_MIN_SECONDS = 0.2;
19
+ /**
20
+ * 末尾(次の字幕が無い場所)へ字幕を足すときの既定尺(秒)。
21
+ * edit-lint の captions.short-duration(1.0 秒未満で warning)に自分から当たらない値を取る。
22
+ */
23
+ exports.CAPTION_LINE_DEFAULT_SECONDS = 1;
24
+ /**
25
+ * baseline(保存済みの表示行)と next(編集後の表示行)の差分を字幕操作列へ写す。
26
+ *
27
+ * アルゴリズムは先頭・末尾から一致行を削る古典的な差分(LCS は使わない)。中央に残った
28
+ * 「旧 n 行 → 新 m 行」の塊を次の順に落とす:
29
+ * - n === 1 && m > 1 → split
30
+ * - n > 1 && m === 1 → merge
31
+ * - それ以外 → min(n, m) 組を replace(テキストが変わった行だけ)+ 余りを remove / insert
32
+ *
33
+ * 中央の塊は 1 つしか残らないので、split と remove のような別種の操作が 1 回の呼び出しで
34
+ * 同時に出ることはない(連続した編集はそのたびに呼ばれる)。
35
+ */
36
+ function diffCaptionLines(baseline, next, captions) {
37
+ if (baseline.length !== captions.length) {
38
+ throw new Error('字幕の行数と字幕データの件数が一致しません。');
39
+ }
40
+ let prefix = 0;
41
+ while (prefix < baseline.length && prefix < next.length && baseline[prefix] === next[prefix]) {
42
+ prefix++;
43
+ }
44
+ let suffix = 0;
45
+ while (suffix < baseline.length - prefix && suffix < next.length - prefix
46
+ && baseline[baseline.length - 1 - suffix] === next[next.length - 1 - suffix]) {
47
+ suffix++;
48
+ }
49
+ const oldCount = baseline.length - prefix - suffix;
50
+ const newCount = next.length - prefix - suffix;
51
+ if (oldCount === 0 && newCount === 0) {
52
+ return [];
53
+ }
54
+ const ids = captions.slice(prefix, prefix + oldCount).map(caption => caption.id);
55
+ const texts = next.slice(prefix, prefix + newCount);
56
+ if (oldCount === 1 && newCount > 1) {
57
+ return [{ kind: 'split', id: ids[0], texts }];
58
+ }
59
+ if (oldCount > 1 && newCount === 1) {
60
+ return [{ kind: 'merge', ids, text: texts[0] }];
61
+ }
62
+ const ops = [];
63
+ const paired = Math.min(oldCount, newCount);
64
+ for (let index = 0; index < paired; index++) {
65
+ if (texts[index] !== baseline[prefix + index]) {
66
+ ops.push({ kind: 'replace', id: ids[index], text: texts[index] });
67
+ }
68
+ }
69
+ for (let index = paired; index < oldCount; index++) {
70
+ ops.push({ kind: 'remove', id: ids[index] });
71
+ }
72
+ if (newCount > paired) {
73
+ // 追加行を差し込む位置は「直前に残る既存行」で表す。塊の中で組になった行があれば
74
+ // その最後、無ければ塊の直前(先頭挿入なら undefined)。
75
+ const afterId = paired > 0
76
+ ? ids[paired - 1]
77
+ : prefix > 0 ? captions[prefix - 1].id : undefined;
78
+ for (let index = paired; index < newCount; index++) {
79
+ ops.push({ kind: 'insert', afterId, text: texts[index] });
80
+ }
81
+ }
82
+ return ops;
83
+ }
84
+ /**
85
+ * split の時刻割り付け(指示 B-2)。元の [start, end] を分割後テキストの **文字数比** で按分し、
86
+ * 境界は元の区間内に収める。words があっても使わない(words の無い字幕と同じ挙動を優先する)。
87
+ * どれか 1 つでも minSeconds を持てないときは undefined を返す(= 分割を拒否)。
88
+ */
89
+ function planSplitSpans(start, end, texts, minSeconds = exports.CAPTION_LINE_MIN_SECONDS) {
90
+ if (texts.length < 2 || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
91
+ return undefined;
92
+ }
93
+ const duration = end - start;
94
+ if (duration < minSeconds * texts.length - EPSILON) {
95
+ return undefined;
96
+ }
97
+ // 文字数は code point 単位で数える(サロゲートペアで比が壊れないように)。
98
+ const weights = texts.map(text => Math.max(1, Array.from(text).length));
99
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
100
+ const spans = [];
101
+ let cursor = start;
102
+ let consumed = 0;
103
+ for (let index = 0; index < texts.length; index++) {
104
+ consumed += weights[index];
105
+ const boundary = index === texts.length - 1
106
+ ? end
107
+ : Math.min(end, Math.max(cursor, roundMilliseconds(start + duration * consumed / total)));
108
+ spans.push({ start: cursor, end: boundary });
109
+ cursor = boundary;
110
+ }
111
+ return spans.every(span => span.end - span.start >= minSeconds - EPSILON) ? spans : undefined;
112
+ }
113
+ /**
114
+ * insert の時刻割り付け(指示 B-5)。直前行の end から次行の start までの隙間へ count 行を置く。
115
+ * 隙間が count × minSeconds に足りないときは undefined を返す(= 追加を拒否)。
116
+ * 次行が無い(末尾への追加)ときは隙間の上限が無いので CAPTION_LINE_DEFAULT_SECONDS ずつ並べる。
117
+ */
118
+ function planInsertSpans(previousEnd, nextStart, count, minSeconds = exports.CAPTION_LINE_MIN_SECONDS) {
119
+ if (count < 1 || !Number.isFinite(previousEnd) || previousEnd < 0) {
120
+ return undefined;
121
+ }
122
+ if (nextStart === undefined) {
123
+ return Array.from({ length: count }, (_unused, index) => ({
124
+ start: roundMilliseconds(previousEnd + exports.CAPTION_LINE_DEFAULT_SECONDS * index),
125
+ end: roundMilliseconds(previousEnd + exports.CAPTION_LINE_DEFAULT_SECONDS * (index + 1))
126
+ }));
127
+ }
128
+ const gap = nextStart - previousEnd;
129
+ if (!Number.isFinite(gap) || gap < minSeconds * count - EPSILON) {
130
+ return undefined;
131
+ }
132
+ const slot = gap / count;
133
+ const duration = Math.min(slot, exports.CAPTION_LINE_DEFAULT_SECONDS);
134
+ return Array.from({ length: count }, (_unused, index) => {
135
+ const spanStart = roundMilliseconds(previousEnd + slot * index);
136
+ return {
137
+ start: spanStart,
138
+ end: Math.min(nextStart, roundMilliseconds(spanStart + duration))
139
+ };
140
+ });
141
+ }
142
+ /**
143
+ * `c-0001`, `c-0002`, ... のうち既存 id と衝突しないものを順に返す採番器
144
+ * (timeline-material-insert.ts の nextSourceId と同型: 使用済み集合 + 衝突する限り繰り上げ)。
145
+ */
146
+ function createCaptionIdAllocator(existingIds) {
147
+ const used = new Set(existingIds);
148
+ let serial = 1;
149
+ for (const id of used) {
150
+ const match = /^c-(\d{4,})$/.exec(id);
151
+ if (match) {
152
+ serial = Math.max(serial, Number(match[1]) + 1);
153
+ }
154
+ }
155
+ return () => {
156
+ let candidate;
157
+ do {
158
+ candidate = `c-${String(serial++).padStart(4, '0')}`;
159
+ } while (used.has(candidate));
160
+ used.add(candidate);
161
+ return candidate;
162
+ };
163
+ }
164
+ const EPSILON = 1e-9;
165
+ function roundMilliseconds(value) {
166
+ return Math.round(value * 1000) / 1000;
167
+ }
@@ -3,7 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "description": "ffmpeg / ffprobe / whisper-cli バイナリ解決の一元化。ffmpeg/ffprobe の探索順: AKARI_FFMPEG_BIN / AKARI_FFPROBE_BIN(明示指定) → FFMPEG_PATH(ffmpeg の既存互換) → PATH → 同梱バイナリ(vendor/ 配下。binary-manifest.mjs でピン留めした GPL-only・真ネイティブビルド)。whisper-cli の探索順は AKARI_WHISPER_BIN → 同梱バイナリ → PATHwin32 は公式リリース zip、macOS は release CI のソースビルド)。",
6
+ "description": "ffmpeg / ffprobe / whisper-cli バイナリ解決の一元化。ffmpeg/ffprobe の探索順: AKARI_FFMPEG_BIN / AKARI_FFPROBE_BIN(明示指定) → FFMPEG_PATH(ffmpeg の既存互換) → PATH → 同梱バイナリ(vendor/ 配下。binary-manifest.mjs でピン留めした GPL-only・真ネイティブビルド) → パッケージ版同梱(extraResources の media-bin/ 配下)。whisper-cli の探索順は AKARI_WHISPER_BIN → vendor 同梱 → PATH → パッケージ版同梱(win32 は公式リリース zip、macOS は release CI のソースビルド)。",
7
7
  "main": "src/index.mjs",
8
8
  "exports": {
9
9
  ".": "./src/index.mjs"
@@ -78,6 +78,18 @@ export function vendorBinaryPath(name, target = currentTarget()) {
78
78
  return path.join(VENDOR_ROOT, target, exeName);
79
79
  }
80
80
 
81
+ /**
82
+ * extraResources の packages/media-bin と media-bin の相対配置から同梱候補を返す。
83
+ * ELECTRON_RUN_AS_NODE の子プロセスにも対応するため process.resourcesPath は使わない。
84
+ * 存在するとは限らないため、呼び出し側が existsSync で確認する。
85
+ * @param {"ffmpeg"|"ffprobe"|"whisper-cli"} name
86
+ * @param {string} [target]
87
+ */
88
+ export function packagedBinaryPath(name, target = currentTarget()) {
89
+ const exeName = target.startsWith("win32-") ? `${name}.exe` : name;
90
+ return path.resolve(packageRoot, "../..", "media-bin", exeName);
91
+ }
92
+
81
93
  const MARTIN_RIEDL_LICENSE =
82
94
  "GPL-3.0-or-later build (--enable-gpl, no --enable-nonfree; libx264/libx265 dual-licensed under GPL). " +
83
95
  "Source: https://git.martin-riedl.de/ffmpeg/build-script — codec/library list: https://ffmpeg.martin-riedl.de/#info";
@@ -3,10 +3,12 @@
3
3
  // ffmpeg/ffprobe の探索順: 明示指定(AKARI_FFMPEG_BIN / AKARI_FFPROBE_BIN。パス区切りのない
4
4
  // 値は PATH で解決) → 既存互換(FFMPEG_PATH、ffmpeg のみ。同じ規則) → PATH → 同梱バイナリ
5
5
  // (vendor/ 配下。binary-manifest.mjs でピン留めした GPL-only・真ネイティブビルドを
6
- // postinstall で取得済み — task/2026-08-01-gpl-only-ffmpeg-swap)。
6
+ // postinstall で取得済み — task/2026-08-01-gpl-only-ffmpeg-swap) → パッケージ版同梱
7
+ // (extraResources の media-bin/ 配下。packageRoot からの相対配置で解決)。
7
8
  //
8
9
  // whisper-cli の探索順は意図的に異なる(task/2026-08-17-media-bin-whisper 契約どおり):
9
- // 明示指定(AKARI_WHISPER_BIN) → 同梱バイナリ → PATH。ffmpeg は「PATH 優先」が既存
10
+ // 明示指定(AKARI_WHISPER_BIN) → vendor 同梱 → PATH パッケージ版同梱。
11
+ // ffmpeg は「PATH 優先」が既存
10
12
  // システムインストールとの互換性のための既定だが、whisper-cli は事前の既存インストール
11
13
  // 慣行が無く、PATH 上に無関係な古い whisper.cpp(CLI 引数の互換性が保証されない)が
12
14
  // 入っている可能性の方がリスクなので、アプリが版固定でピン留めした同梱バイナリを優先する。
@@ -18,7 +20,7 @@ import { spawnSync } from "node:child_process";
18
20
  import { existsSync } from "node:fs";
19
21
  import { platform } from "node:os";
20
22
 
21
- import { vendorBinaryPath } from "./binary-manifest.mjs";
23
+ import { vendorBinaryPath, packagedBinaryPath } from "./binary-manifest.mjs";
22
24
 
23
25
  const FFMPEG_INSTALL_HINT = {
24
26
  darwin: " brew install ffmpeg",
@@ -66,7 +68,7 @@ function resolveExplicit(envVar, value, env) {
66
68
  );
67
69
  }
68
70
 
69
- function notFoundMessage({ label, envVar, legacyEnvVar, vendorPath, installHint, preferVendorOverPath }) {
71
+ function notFoundMessage({ label, envVar, legacyEnvVar, vendorPath, packagedPath, installHint, preferVendorOverPath }) {
70
72
  const legacyStep = legacyEnvVar ? ` → ${legacyEnvVar}(既存互換・同じ規則)` : "";
71
73
  const searchOrder = preferVendorOverPath
72
74
  ? `${envVar}(明示指定: 絶対パス / PATH 上のコマンド名)${legacyStep} → 同梱バイナリ(${vendorPath}) → PATH`
@@ -75,6 +77,7 @@ function notFoundMessage({ label, envVar, legacyEnvVar, vendorPath, installHint,
75
77
  `${label} が見つかりませんでした。`,
76
78
  "",
77
79
  `探索順: ${searchOrder}`,
80
+ ` → パッケージ版同梱バイナリ(${packagedPath})`,
78
81
  "",
79
82
  "同梱バイナリは packages/media-bin の npm install(postinstall)で取得されるはずですが、",
80
83
  "見つかりませんでした。npm install をやり直すか、対応プラットフォームが無い場合は",
@@ -114,7 +117,10 @@ function resolveBinary({
114
117
 
115
118
  if (vendorExists) return vendorPath;
116
119
 
117
- throw new Error(notFoundMessage({ label, envVar, legacyEnvVar, vendorPath, installHint, preferVendorOverPath }));
120
+ const packagedPath = packagedBinaryPath(vendorName);
121
+ if (existsSync(packagedPath)) return packagedPath;
122
+
123
+ throw new Error(notFoundMessage({ label, envVar, legacyEnvVar, vendorPath, packagedPath, installHint, preferVendorOverPath }));
118
124
  }
119
125
 
120
126
  /**
@@ -149,7 +155,8 @@ export function resolveFfprobe({ env = process.env } = {}) {
149
155
 
150
156
  /**
151
157
  * whisper-cli バイナリの絶対パス(または PATH 解決に委ねるコマンド名)を返す。
152
- * 探索順は env → 同梱バイナリ → PATH(ffmpeg とは順序が異なる — ファイル冒頭コメント参照)。
158
+ * 探索順は env → vendor 同梱 → PATH パッケージ版同梱
159
+ * (ffmpeg とは順序が異なる — ファイル冒頭コメント参照)。
153
160
  * @param {{ env?: NodeJS.ProcessEnv }} [opts]
154
161
  */
155
162
  export function resolveWhisperCli({ env = process.env } = {}) {
@@ -1,11 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
3
4
  import { mkdtemp, rm, writeFile, chmod } from "node:fs/promises";
5
+ import { syncBuiltinESMExports } from "node:module";
4
6
  import { tmpdir } from "node:os";
5
7
  import path from "node:path";
6
8
  import test from "node:test";
9
+ import { fileURLToPath } from "node:url";
7
10
 
8
11
  import { resolveFfmpeg, resolveFfprobe } from "../src/index.mjs";
12
+ import { packagedBinaryPath, vendorBinaryPath } from "../src/binary-manifest.mjs";
9
13
 
10
14
  // システムに ffmpeg/ffprobe が入っていても影響を受けないよう、実行の度に
11
15
  // AKARI_*_BIN / FFMPEG_PATH を明示的に undefined へ倒したベース env を組み立てる。
@@ -23,6 +27,39 @@ function baseEnv(overrides = {}) {
23
27
  // フォールバックだけが効く状態を作る(task.md 記載の `env PATH=/usr/bin:/bin` と同じ狙い)。
24
28
  const STRIPPED_PATH_ENV = baseEnv({ PATH: "/usr/bin:/bin" });
25
29
 
30
+ test("packagedBinaryPath: packageRoot の ../../media-bin を候補にし、win32 は .exe を付ける", () => {
31
+ const packageRoot = fileURLToPath(new URL("../", import.meta.url));
32
+ for (const name of ["ffmpeg", "ffprobe", "whisper-cli"]) {
33
+ for (const target of ["darwin-arm64", "linux-x64", "win32-x64"]) {
34
+ const exe = target.startsWith("win32-") ? `${name}.exe` : name;
35
+ assert.equal(packagedBinaryPath(name, target), path.resolve(packageRoot, "../../media-bin", exe));
36
+ }
37
+ }
38
+ });
39
+
40
+ test("resolveFfmpeg: vendor / packaged 不在時のエラーに packaged 候補パスを含める", t => {
41
+ const vendorPath = vendorBinaryPath("ffmpeg");
42
+ const packagedPath = packagedBinaryPath("ffmpeg");
43
+ // バイナリを移動せず、候補 2 つの存在確認だけを不在にする。
44
+ const existsSync = fs.existsSync;
45
+ const mockedExists = t.mock.method(fs, "existsSync", candidate =>
46
+ candidate === vendorPath || candidate === packagedPath ? false : existsSync(candidate)
47
+ );
48
+ syncBuiltinESMExports();
49
+ t.after(() => {
50
+ mockedExists.mock.restore();
51
+ syncBuiltinESMExports();
52
+ });
53
+
54
+ assert.throws(() => resolveFfmpeg({ env: STRIPPED_PATH_ENV }), error => {
55
+ assert.match(error.message, /ffmpeg が見つかりませんでした/);
56
+ assert.match(error.message, /探索順:/);
57
+ assert.ok(error.message.includes(vendorPath));
58
+ assert.ok(error.message.includes(`パッケージ版同梱バイナリ(${packagedPath})`));
59
+ return true;
60
+ });
61
+ });
62
+
26
63
  async function withFixtureFile(run) {
27
64
  const dir = await mkdtemp(path.join(tmpdir(), "akari-media-bin-"));
28
65
  const filePath = path.join(dir, "fake-ffmpeg");
@@ -22,47 +22,48 @@ async function loadDependencies() {
22
22
  }
23
23
 
24
24
  const usage = "使い方: node skills/manage-connections/bin/doctor.mjs [プロジェクトルート|connections.json]";
25
- const argument = process.argv[2];
25
+ export const timeoutMs = 5_000;
26
26
 
27
- if (process.argv.length > 3) {
28
- console.error(usage);
29
- process.exit(2);
30
- }
27
+ async function main() {
28
+ const argument = process.argv[2];
31
29
 
32
- if (argument === "--help" || argument === "-h") {
33
- console.log(usage);
34
- process.exit(0);
35
- }
30
+ if (process.argv.length > 3) {
31
+ console.error(usage);
32
+ process.exit(2);
33
+ }
36
34
 
37
- const inputPath = path.resolve(argument ?? process.cwd());
38
- const connectionsPath = inputPath.endsWith(".json")
39
- ? inputPath
40
- : path.join(inputPath, ".akari", "connections.json");
41
- const projectRoot = inputPath.endsWith(".json")
42
- ? path.dirname(path.dirname(connectionsPath))
43
- : inputPath;
44
- const reportPath = path.join(projectRoot, "connections-report.html");
45
- const credentialsPath = path.resolve(
46
- process.env.AKARI_CREDENTIALS_FILE ?? path.join(os.homedir(), ".config", "akari-video", "credentials.env"),
47
- );
48
- const timeoutMs = 5_000;
35
+ if (argument === "--help" || argument === "-h") {
36
+ console.log(usage);
37
+ process.exit(0);
38
+ }
49
39
 
50
- async function main() {
40
+ const inputPath = path.resolve(argument ?? process.cwd());
41
+ const connectionsPath = inputPath.endsWith(".json")
42
+ ? inputPath
43
+ : path.join(inputPath, ".akari", "connections.json");
44
+ const projectRoot = inputPath.endsWith(".json")
45
+ ? path.dirname(path.dirname(connectionsPath))
46
+ : inputPath;
47
+ const reportPath = path.join(projectRoot, "connections-report.html");
48
+ const credentialsPath = path.resolve(
49
+ process.env.AKARI_CREDENTIALS_FILE ?? path.join(os.homedir(), ".config", "akari-video", "credentials.env"),
50
+ );
51
51
  await loadDependencies();
52
+ const context = { inputPath, connectionsPath, projectRoot, reportPath, credentialsPath };
52
53
  if (inputPath.endsWith(".json")) {
53
- await mainLegacy();
54
+ await mainLegacy(context);
54
55
  return;
55
56
  }
56
- await mainResolved();
57
+ await mainResolved(context);
57
58
  }
58
59
 
59
- async function mainLegacy() {
60
- const registry = readRegistry();
61
- const credentialState = readCredentials();
60
+ async function mainLegacy({ connectionsPath, reportPath, credentialsPath }) {
61
+ const registry = readRegistry(connectionsPath);
62
+ const credentialState = readCredentials(credentialsPath);
62
63
  const checkedAt = new Date().toISOString();
63
64
 
64
65
  if (!credentialState.exists) {
65
- printCredentialGuide(registry);
66
+ printCredentialGuide(registry, credentialsPath);
66
67
  } else if (!credentialState.securePermissions) {
67
68
  console.warn(`警告: credentials.env の権限は 600 ではありません(現在 ${credentialState.mode})。chmod 600 ${credentialsPath} を実行してください。`);
68
69
  }
@@ -74,7 +75,7 @@ async function mainLegacy() {
74
75
 
75
76
  const jsonOutput = `${JSON.stringify(registry, null, 2)}\n`;
76
77
  refuseSecretLeak(jsonOutput, credentialState.values);
77
- const htmlOutput = renderReport(registry, results, credentialState, checkedAt);
78
+ const htmlOutput = renderReport(registry, results, credentialState, checkedAt, credentialsPath);
78
79
  refuseSecretLeak(htmlOutput, credentialState.values);
79
80
 
80
81
  fs.writeFileSync(connectionsPath, jsonOutput, "utf8");
@@ -86,14 +87,14 @@ async function mainLegacy() {
86
87
  console.log(`接続レポート: ${reportPath}`);
87
88
  }
88
89
 
89
- async function mainResolved() {
90
+ async function mainResolved({ projectRoot, reportPath, credentialsPath }) {
90
91
  const resolved = await resolveConnections({ projectRoot });
91
92
  const registry = clone(resolved.effective);
92
- const credentialState = readCredentials();
93
+ const credentialState = readCredentials(credentialsPath);
93
94
  const checkedAt = new Date().toISOString();
94
95
 
95
96
  if (!credentialState.exists) {
96
- printCredentialGuide(registry);
97
+ printCredentialGuide(registry, credentialsPath);
97
98
  } else if (!credentialState.securePermissions) {
98
99
  console.warn(`警告: credentials.env の権限は 600 ではありません(現在 ${credentialState.mode})。chmod 600 ${credentialsPath} を実行してください。`);
99
100
  }
@@ -129,7 +130,7 @@ async function mainResolved() {
129
130
  writeRegistry(resolved.layers.workspace.path, workspaceRegistry, credentialState.values);
130
131
  }
131
132
 
132
- const htmlOutput = renderReport(registry, results, credentialState, checkedAt);
133
+ const htmlOutput = renderReport(registry, results, credentialState, checkedAt, credentialsPath);
133
134
  refuseSecretLeak(htmlOutput, credentialState.values);
134
135
  fs.writeFileSync(reportPath, htmlOutput, "utf8");
135
136
 
@@ -144,7 +145,7 @@ async function mainResolved() {
144
145
  console.log(`接続レポート: ${reportPath}`);
145
146
  }
146
147
 
147
- function readRegistry() {
148
+ function readRegistry(connectionsPath) {
148
149
  let value;
149
150
  try {
150
151
  value = JSON.parse(fs.readFileSync(connectionsPath, "utf8"));
@@ -198,7 +199,7 @@ function clone(value) {
198
199
  return JSON.parse(JSON.stringify(value));
199
200
  }
200
201
 
201
- function readCredentials() {
202
+ function readCredentials(credentialsPath) {
202
203
  let stat;
203
204
  try {
204
205
  stat = fs.statSync(credentialsPath);
@@ -277,7 +278,7 @@ async function inspectProvider(provider, credentialState, checkedAt) {
277
278
  return { id, configured: true, doctor };
278
279
  }
279
280
 
280
- const adapters = {
281
+ export const adapters = {
281
282
  fal: (secret, checkedAt) =>
282
283
  checkGet("https://rest.alpha.fal.ai/billing/user_balance", { Authorization: `Key ${secret}` }, checkedAt),
283
284
  replicate: (secret, checkedAt) =>
@@ -375,7 +376,7 @@ function extractEnvName(value) {
375
376
  return match ? match[1] : null;
376
377
  }
377
378
 
378
- function printCredentialGuide(registry) {
379
+ function printCredentialGuide(registry, credentialsPath) {
379
380
  console.log(`credentials.env がありません: ${credentialsPath}`);
380
381
  console.log(`作成後に chmod 600 ${credentialsPath} を実行してください。キーは人間が取得して配置してください。`);
381
382
  for (const provider of registry.providers) {
@@ -394,7 +395,7 @@ function refuseSecretLeak(output, values) {
394
395
  }
395
396
  }
396
397
 
397
- function renderReport(registry, results, credentialState, checkedAt) {
398
+ function renderReport(registry, results, credentialState, checkedAt, credentialsPath) {
398
399
  const resultById = new Map(results.map((item) => [item.id, item]));
399
400
  const cards = registry.providers.map((provider) => {
400
401
  const result = resultById.get(provider.id);