akari-video 0.1.79 → 0.1.80

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.79",
3
+ "version": "0.1.80",
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": {
@@ -41,6 +41,8 @@ const commandUsage = [
41
41
  "サブコマンド:",
42
42
  " generate 原稿からナレーション音声を生成する",
43
43
  " engines エンジン一覧を表示する",
44
+ " start VOICEVOX エンジンを起動する",
45
+ " stop AKARI が起動した VOICEVOX エンジンを止める",
44
46
  " voices 声一覧を表示する",
45
47
  "",
46
48
  usage,
@@ -358,12 +360,14 @@ async function synthesizeFal(readingText, meta, falKey) {
358
360
  * 純粋関数にして `platform` / `env` を注入でき、実プラットフォームに依存せずテストできる
359
361
  * ようにする(darwin 既定パスは不変。win32 既定パスの根拠は report.md 参照)。
360
362
  */
361
- export function resolveVoicevoxRunPath(platform = process.platform, env = process.env) {
363
+ export function resolveVoicevoxRunPath(platform = process.platform, env = process.env, exists = fs.existsSync) {
362
364
  const override = env[VOICEVOX_RUN_ENV];
363
365
  if (override) return override;
364
366
 
365
367
  if (platform === "darwin") {
366
- return "/Applications/VOICEVOX.app/Contents/Resources/vv-engine/run";
368
+ const candidates = ["/Applications/VOICEVOX.app/Contents/Resources/vv-engine/run",
369
+ path.join(env.HOME || os.homedir(), "Applications", "VOICEVOX.app", "Contents", "Resources", "vv-engine", "run")];
370
+ return candidates.find(candidate => exists(candidate)) || candidates[0];
367
371
  }
368
372
  if (platform === "win32") {
369
373
  // VOICEVOX 0.16+ の既定インストーラ配置先(root repo 未検証・GitHub issue で確認済み。
@@ -428,6 +432,108 @@ async function getVoicevoxVersion() {
428
432
  return String(await response.json()).trim();
429
433
  }
430
434
 
435
+ function voicevoxPidPath(env = process.env, homeDir = os.homedir()) {
436
+ return path.join(env.AKARI_HOME || path.join(homeDir, ".akari"), "run", "voicevox.pid");
437
+ }
438
+
439
+ async function probeVoicevox(fetchImpl = fetch) {
440
+ const controller = new AbortController();
441
+ const timer = setTimeout(() => controller.abort(), 1500);
442
+ try {
443
+ const response = await fetchImpl(`${VOICEVOX_BASE_URL}/version`, { signal: controller.signal });
444
+ return response.ok ? { running: true, version: String(await response.json()).trim() } : { running: false };
445
+ } catch { return { running: false }; }
446
+ finally { clearTimeout(timer); }
447
+ }
448
+
449
+ function defaultProcessAlive(pid) {
450
+ try { process.kill(pid, 0); return true; }
451
+ catch { return false; }
452
+ }
453
+
454
+ function defaultProcessCommand(pid) {
455
+ if (process.platform === "win32") return null;
456
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
457
+ encoding: "utf8", timeout: 3_000, maxBuffer: 64 * 1024,
458
+ });
459
+ return result.status === 0 ? result.stdout.trim() : null;
460
+ }
461
+
462
+ function managedVoicevoxPid(runtime = {}) {
463
+ const fileSystem = runtime.fsImpl || fs;
464
+ const env = runtime.env || process.env;
465
+ const pidPath = runtime.pidPath || voicevoxPidPath(env, runtime.homeDir);
466
+ let pid;
467
+ try {
468
+ pid = Number(fileSystem.readFileSync(pidPath, "utf8").trim());
469
+ } catch { return null; }
470
+ let runPath;
471
+ try {
472
+ runPath = resolveVoicevoxRunPath(runtime.platform || process.platform, env,
473
+ candidate => fileSystem.existsSync(candidate));
474
+ } catch { /* 実行ファイルを特定できなければ停止しない */ }
475
+ let command = null;
476
+ try {
477
+ if (Number.isSafeInteger(pid) && pid > 0 && (runtime.isProcessAlive || defaultProcessAlive)(pid)) {
478
+ command = (runtime.readProcessCommand || defaultProcessCommand)(pid);
479
+ }
480
+ } catch { /* 照合できない PID は stale として扱う */ }
481
+ const matches = runPath && typeof command === "string" &&
482
+ [runPath, `"${runPath}"`].some(executable => command === executable || command.startsWith(`${executable} `));
483
+ if (matches) return pid;
484
+ fileSystem.rmSync(pidPath, { force: true });
485
+ return null;
486
+ }
487
+
488
+ async function startVoicevox(runtime = {}) {
489
+ const fetchImpl = runtime.fetchImpl || fetch;
490
+ const fileSystem = runtime.fsImpl || fs;
491
+ const env = runtime.env || process.env;
492
+ const pidPath = runtime.pidPath || voicevoxPidPath(env, runtime.homeDir);
493
+ managedVoicevoxPid(runtime); // 前回の PID が stale なら、既に別経路で起動したアプリを守るため削除する。
494
+ const probe = await probeVoicevox(fetchImpl);
495
+ if (probe.running) return { status: "ok", already_running: true, version: probe.version };
496
+ const runPath = resolveVoicevoxRunPath(runtime.platform || process.platform, env, candidate => fileSystem.existsSync(candidate));
497
+ if (!fileSystem.existsSync(runPath)) throw new PublicError("VOICEVOX エンジンが見つかりません。公式サイトから導入してください。");
498
+ const child = (runtime.spawnImpl || spawn)(runPath, [], { detached: true, windowsHide: true, stdio: "ignore", env });
499
+ child.on?.('error', () => { /* pid が無い場合は直後に失敗として扱う */ });
500
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) throw new PublicError("VOICEVOX を起動できませんでした。");
501
+ child.unref();
502
+ fileSystem.mkdirSync(path.dirname(pidPath), { recursive: true });
503
+ fileSystem.writeFileSync(pidPath, `${child.pid}\n`, { mode: 0o600 });
504
+ const now = runtime.now || Date.now;
505
+ const pause = runtime.sleep || sleep;
506
+ const deadline = now() + VOICEVOX_STARTUP_TIMEOUT_MS;
507
+ while (now() < deadline) {
508
+ const state = await probeVoicevox(fetchImpl);
509
+ if (state.running) return { status: "ok", already_running: false, version: state.version };
510
+ await pause(1000);
511
+ }
512
+ try { (runtime.killProcess || process.kill)(child.pid, "SIGTERM"); } catch { /* 終了済み */ }
513
+ fileSystem.rmSync(pidPath, { force: true });
514
+ throw new PublicError("VOICEVOX エンジンの起動が 60 秒でタイムアウトしました。");
515
+ }
516
+
517
+ async function stopManagedVoicevox(runtime = {}) {
518
+ const fileSystem = runtime.fsImpl || fs;
519
+ const pidPath = runtime.pidPath || voicevoxPidPath(runtime.env || process.env, runtime.homeDir);
520
+ const pid = managedVoicevoxPid(runtime);
521
+ if (!pid) return { status: "ok", stopped: false, managed: false };
522
+ try { (runtime.killProcess || process.kill)(pid, "SIGTERM"); }
523
+ catch (error) {
524
+ if (error?.code !== "ESRCH") throw error;
525
+ fileSystem.rmSync(pidPath, { force: true });
526
+ return { status: "ok", stopped: false, managed: false };
527
+ }
528
+ fileSystem.rmSync(pidPath, { force: true });
529
+ const pause = runtime.sleep || sleep;
530
+ for (let attempt = 0; attempt < 20; attempt++) {
531
+ if (!(await probeVoicevox(runtime.fetchImpl || fetch)).running) break;
532
+ await pause(250);
533
+ }
534
+ return { status: "ok", stopped: true, managed: true };
535
+ }
536
+
431
537
  async function resolveVoicevoxSpeakerName(speakerId) {
432
538
  const response = await fetch(`${VOICEVOX_BASE_URL}/speakers`);
433
539
  if (!response.ok) throw new PublicError(`VOICEVOX /speakers が HTTP ${response.status} を返しました`);
@@ -506,16 +612,22 @@ function maskKey(secret) {
506
612
  return secret ? "***configured***" : "***unconfigured***";
507
613
  }
508
614
 
509
- async function listEngines() {
615
+ async function listEngines(runtime = {}) {
510
616
  let voicevox;
511
- if (await isVoicevoxUp()) {
512
- voicevox = { state: "available", label: "VOICEVOX を使用できます" };
617
+ const state = await probeVoicevox(runtime.fetchImpl || fetch);
618
+ const fileSystem = runtime.fsImpl || fs;
619
+ let appFound = false;
620
+ try { appFound = fileSystem.existsSync(resolveVoicevoxRunPath(runtime.platform || process.platform, runtime.env || process.env,
621
+ candidate => fileSystem.existsSync(candidate))); } catch { /* 未対応 OS */ }
622
+ const ownedPid = managedVoicevoxPid(runtime);
623
+ const detail = { running: state.running, ...(state.version ? { version: state.version } : {}), app_found: appFound,
624
+ managed: state.running && Boolean(ownedPid) };
625
+ if (state.running) {
626
+ voicevox = { state: "available", label: "VOICEVOX を使用できます", detail };
513
627
  } else {
514
- let installed = false;
515
- try { installed = fs.existsSync(resolveVoicevoxRunPath()); } catch { /* 未対応 OS */ }
516
- voicevox = installed
517
- ? { state: "needs", label: "VOICEVOX を起動します(自動)" }
518
- : { state: "unconfigured", label: "VOICEVOX をインストール", detail: { setup_url: "https://voicevox.hiroshiba.jp/" } };
628
+ voicevox = appFound
629
+ ? { state: "needs", label: "VOICEVOX を起動します(自動)", detail }
630
+ : { state: "unconfigured", label: "VOICEVOX をインストール", detail: { ...detail, setup_url: "https://voicevox.hiroshiba.jp/" } };
519
631
  }
520
632
  const configured = Boolean(readCredentials().get("FAL_KEY"));
521
633
  const falAvailability = configured
@@ -744,10 +856,23 @@ export async function runNarrationCommand(args, commandOptions = {}) {
744
856
  io.log(usage);
745
857
  return { exitCode: 0 };
746
858
  }
859
+ if (["start", "stop"].includes(args[0])) {
860
+ try {
861
+ if (args.length !== 4 || args[1] !== "--engine" || args[2] !== "voicevox" || args[3] !== "--json") {
862
+ throw new PublicError("start/stop には --engine voicevox --json が必要です", 2);
863
+ }
864
+ printCompactJson(args[0] === "start" ? await startVoicevox(commandOptions.engineRuntime) : await stopManagedVoicevox(commandOptions.engineRuntime), io.log);
865
+ return { exitCode: 0 };
866
+ } catch (error) {
867
+ const message = error instanceof PublicError ? error.message : `VOICEVOX を操作できませんでした: ${error?.message ?? error}`;
868
+ io.logError(message); printCompactJson({ error: message }, io.log);
869
+ return { exitCode: error instanceof PublicError ? error.exitCode : 1 };
870
+ }
871
+ }
747
872
  if (["engines", "voices"].includes(args[0])) {
748
873
  try {
749
874
  const engine = parseListArguments(args);
750
- printCompactJson(args[0] === "engines" ? await listEngines() : { version: 1, engine, voices: await listVoices(engine) }, io.log);
875
+ printCompactJson(args[0] === "engines" ? await listEngines(commandOptions.engineRuntime) : { version: 1, engine, voices: await listVoices(engine) }, io.log);
751
876
  return { exitCode: 0 };
752
877
  } catch (error) {
753
878
  const message = error instanceof PublicError ? error.message : "声一覧を取得できませんでした";
@@ -64,8 +64,8 @@ cut 境界の選択は宣言順ではなく解決済みタイムラインと z-o
64
64
  > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
65
65
  > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
66
66
  > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
67
- > overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
68
- > 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
67
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
68
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
69
69
 
70
70
  - `framing.crop` は fit 済みフレームを窓抜きして出力寸法へ再拡大する。
71
71
  - `framing.keyframes`、transform keyframes は cut 内の出力秒で評価し、hold / linear / ease-in-out の
@@ -23,8 +23,8 @@ updated: 2026-08-30
23
23
  > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
24
24
  > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
25
25
  > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
26
- > overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
27
- > 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
26
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
27
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
28
28
 
29
29
  - `keyframes[]` はレイヤー / アイテムの `transform` / `crop` / `perspective` を時間で動かす共通機構
30
30
  - `t` は**ローカル時間**: v1 `layers[].keyframes[].t` はレイヤー内秒(`layerItem.t` を 0 とする。`cuts[].framing.keyframes[].t` と同じ規約)、
@@ -69,8 +69,8 @@ updated: 2026-09-06
69
69
  > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
70
70
  > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
71
71
  > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
72
- > overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
73
- > 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
72
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
73
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
74
74
 
75
75
  既存 `keyframeV2`(点ごとに任意プロパティ・`t` はアイテム相対の整数フレーム)を**唯一の形**として保ち、次を足す:
76
76
 
@@ -235,6 +235,8 @@ Kling v3 standard i2v / Kling v3 pro i2v / Veo 3.1 first-last / Veo 3.1 referenc
235
235
 
236
236
  ## 9. 用語(混同を防ぐ)
237
237
 
238
+ 右パネルのタブ表示名は「AI」(id は `generation`)。入口は行為のタイル一覧で、選ぶと既存の生成フォームを専用パネルに表示する。
239
+
238
240
  | 語 | 意味 |
239
241
  |---|---|
240
242
  | 仮枠 | 尺と場所を持つ静止画クリップ。完成品でもある |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
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": [
@@ -18,6 +18,7 @@ import { cacheCatalog, loadCatalog } from '../src/catalog.mjs';
18
18
  import { resolve as resolveAsset } from '../src/resolve.mjs';
19
19
  import { DEFAULT_CATALOG_URL, DEFAULT_STORE_API } from '../src/service-urls.mjs';
20
20
  import { composeState } from '../src/state.mjs';
21
+ import { checkLibrary, projectCredits } from '../src/library-check.mjs';
21
22
 
22
23
  function flagValue(args, name) {
23
24
  const i = args.indexOf(name);
@@ -34,6 +35,8 @@ function validateArgs(sub, args) {
34
35
  migrate: { values: [], flags: ['--dry-run'], max: 0 },
35
36
  sync: { values: [], flags: [], max: 0 },
36
37
  browse: { values: ['--port'], flags: [], max: 0 },
38
+ check: { values: ['--project'], flags: ['--json'], max: 0 },
39
+ credits: { values: ['--project'], flags: [], max: 0 },
37
40
  };
38
41
  const spec = specs[sub];
39
42
  if (!spec) throw new Error(`不明なコマンド: ${sub}`);
@@ -53,6 +56,7 @@ function validateArgs(sub, args) {
53
56
  throw new Error('add は <path...> --plan または --apply <plan.json> を指定してください');
54
57
  }
55
58
  if (sub === 'bundle' && !seen.has('--project')) throw new Error('--project <dir> が必要です');
59
+ if (sub === 'credits' && !seen.has('--project')) throw new Error('--project <dir> が必要です');
56
60
  if (sub === 'fetch') {
57
61
  if (args[0] !== positional[0]) throw new Error('fetch の先頭には素材 ID を指定してください');
58
62
  if (seen.has('--reference') && !seen.has('--project')) throw new Error('--reference には --project <dir> が必要です');
@@ -177,8 +181,22 @@ async function cmdBrowse(args, env) {
177
181
  // サーバプロセスを起動したまま維持する(declare-server.mjs 等と同じ流儀)
178
182
  }
179
183
 
184
+ async function cmdCheck(args, env) {
185
+ const result = await checkLibrary({ env, project: flagValue(args, '--project') });
186
+ if (args.includes('--json')) console.log(JSON.stringify(result, null, 2));
187
+ else {
188
+ console.log(`問題なし ${result.ok} 件 / 注意 ${result.warnings.length} 件 / エラー ${result.errors.length} 件`);
189
+ for (const row of result.findings) console.log(`${row.level === 'error' ? 'エラー' : '注意'}: ${row.category}/${row.id}: ${row.message} (${row.dir})`);
190
+ }
191
+ if (result.errors.length) process.exitCode = 1;
192
+ }
193
+
194
+ async function cmdCredits(args, env) {
195
+ console.log((await projectCredits(flagValue(args, '--project'), env)).join('\n'));
196
+ }
197
+
180
198
  function printUsage() {
181
- console.log(`使い方: akari-assets <list|add|fetch|bundle|migrate|sync|browse> [options]
199
+ console.log(`使い方: akari-assets <list|add|fetch|bundle|migrate|sync|browse|check|credits> [options]
182
200
 
183
201
  list [--category <c>] [--source <lab|site|own>] [--json]
184
202
  出どころ・取得状態つき素材一覧
@@ -190,6 +208,8 @@ function printUsage() {
190
208
  migrate [--dry-run] ライブラリを作業場へ移行
191
209
  sync カタログを取得してローカルにキャッシュ(オフライン用)
192
210
  browse [--port <n>] ローカル HTTP サーバでカタログを閲覧・投入(既定 8910)
211
+ check [--project <dir>] [--json] ライブラリを読み取り専用で点検
212
+ credits --project <dir> プロジェクトのクレジット文面を一覧
193
213
 
194
214
  環境変数:
195
215
  AKARI_HOME マシン設定の置き場(既定: ~/.akari)
@@ -227,6 +247,8 @@ async function main() {
227
247
  if (sub === 'bundle') return cmdBundle(rest, env);
228
248
  if (sub === 'sync') return cmdSync(rest, env);
229
249
  if (sub === 'browse') return cmdBrowse(rest, env);
250
+ if (sub === 'check') return cmdCheck(rest, env);
251
+ if (sub === 'credits') return cmdCredits(rest, env);
230
252
 
231
253
  printUsage();
232
254
  process.exitCode = sub && sub !== '--help' && sub !== '-h' ? 1 : 0;
@@ -0,0 +1,151 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { lstat, open, readFile, readdir, realpath, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
7
+ import { resolveFfprobe } from '../../media-bin/src/index.mjs';
8
+ import { ASSET_CATEGORIES, primaryMediaFile } from './library.mjs';
9
+ import { readProjectReferences } from './project-references.mjs';
10
+
11
+ const validator = fileURLToPath(new URL('../../schemas/bin/validate-asset.mjs', import.meta.url));
12
+ const conversion = /\.(aif|aiff|flac|ogg|webm|mkv|avi|gif|svg|woff|woff2|gltf)$/i;
13
+ const issue = (level, category, id, dir, code, message) => ({ level, category, id, dir, code, message });
14
+
15
+ async function directories(env) {
16
+ const seen = new Set();
17
+ const rows = [];
18
+ for (const root of resolveAssetLibraryRoots(env).read) {
19
+ for (const category of ASSET_CATEGORIES) {
20
+ let names = [];
21
+ try { names = await readdir(path.join(root, category)); } catch { continue; }
22
+ for (const id of names.sort()) {
23
+ if (id.startsWith('.') || id === '..' || seen.has(`${category}/${id}`)) continue;
24
+ const dir = path.join(root, category, id);
25
+ try { if (!(await lstat(dir)).isDirectory()) continue; } catch { continue; }
26
+ seen.add(`${category}/${id}`);
27
+ rows.push({ category, id, dir });
28
+ }
29
+ }
30
+ }
31
+ return rows;
32
+ }
33
+
34
+ function signatureOk(file, bytes) {
35
+ const ext = path.extname(file).toLowerCase();
36
+ if (ext === '.glb') return bytes.subarray(0, 4).toString() === 'glTF';
37
+ if (ext === '.gltf') return bytes.toString('utf8').trimStart().startsWith('{');
38
+ if (ext === '.ttf') return bytes.subarray(0, 4).toString('hex') === '00010000';
39
+ if (ext === '.otf') return bytes.subarray(0, 4).toString() === 'OTTO';
40
+ if (ext === '.woff') return bytes.subarray(0, 4).toString() === 'wOFF';
41
+ if (ext === '.woff2') return bytes.subarray(0, 4).toString() === 'wOF2';
42
+ return true;
43
+ }
44
+
45
+ export async function checkLibrary({ env = process.env, project } = {}) {
46
+ if (project && !(await stat(project)).isDirectory()) throw new Error('プロジェクトがフォルダではありません');
47
+ const findings = [];
48
+ const rows = await directories(env);
49
+ for (const { category, id, dir } of rows) {
50
+ const validated = spawnSync(process.execPath, [validator, dir], { encoding: 'utf8', timeout: 30000 });
51
+ if (validated.status !== 0 || validated.error) findings.push(issue('error', category, id, dir, 'meta',
52
+ `meta.json / 素材契約: ${(validated.stderr || validated.stdout || validated.error?.message || '検証失敗').trim()}`));
53
+ let meta;
54
+ try { meta = JSON.parse(await readFile(path.join(dir, 'meta.json'), 'utf8')); } catch { /* validator reports this */ }
55
+ let files;
56
+ try { files = (await readdir(dir)).map(name => ({ name })); }
57
+ catch (error) {
58
+ findings.push(issue('error', category, id, dir, 'unreadable-directory', `素材ディレクトリを読めません: ${error.message}`));
59
+ continue;
60
+ }
61
+ const media = primaryMediaFile(category, files);
62
+ if (['audio', 'broll', 'font'].includes(category) && !media) {
63
+ findings.push(issue('error', category, id, dir, 'media-missing', '主メディアが見つかりません'));
64
+ } else if (media) {
65
+ const file = path.join(dir, media);
66
+ try {
67
+ const handle = await open(file, 'r');
68
+ let bytes;
69
+ try {
70
+ bytes = Buffer.alloc(512);
71
+ const read = await handle.read(bytes, 0, bytes.length, 0);
72
+ if (!read.bytesRead) throw new Error('内容を読めません');
73
+ } finally { await handle.close(); }
74
+ if (!signatureOk(file, bytes)) throw new Error('署名または内容を読めません');
75
+ if (/\.gltf$/i.test(media)) JSON.parse(await readFile(file, 'utf8'));
76
+ if (/\.svg$/i.test(media) && !(await readFile(file, 'utf8')).includes('<svg')) throw new Error('SVG を読めません');
77
+ if (category === 'audio' || category === 'broll' || (category === 'still' && !/\.svg$/i.test(media))) {
78
+ const probe = resolveFfprobe({ env });
79
+ const tested = spawnSync(probe, ['-v', 'error', '-show_entries', 'stream=codec_name:format=duration', '-of', 'json', file], { encoding: 'utf8', timeout: 30000 });
80
+ const data = JSON.parse(tested.stdout);
81
+ if (tested.status !== 0 || !data.streams?.length
82
+ || ((category === 'audio' || category === 'broll') && !Number.isFinite(Number(data.format?.duration)))) throw new Error('ffprobe で読めません');
83
+ }
84
+ } catch (error) { findings.push(issue('error', category, id, dir, 'media-unreadable', `主メディアを読めません: ${error.message}`)); }
85
+ if (conversion.test(media)) findings.push(issue('warning', category, id, dir, 'conversion', '書き出し時に形式の変換が必要です'));
86
+ }
87
+ if (files.some(file => /\.cube$/i.test(file.name))) {
88
+ for (const file of files.filter(entry => /\.cube$/i.test(entry.name))) {
89
+ try {
90
+ const lines = (await readFile(path.join(dir, file.name), 'utf8')).split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'));
91
+ const size = Number(lines.find(line => line.startsWith('LUT_3D_SIZE '))?.split(/\s+/)[1]);
92
+ const triples = lines.filter(line => /^[+\-\d.]/.test(line));
93
+ if (!Number.isInteger(size) || size < 2 || triples.length !== size ** 3
94
+ || triples.some(line => line.split(/\s+/).length !== 3 || line.split(/\s+/).some(value => !Number.isFinite(Number(value))))) {
95
+ findings.push(issue('error', category, id, dir, 'cube', 'cube の行の形が不正です'));
96
+ }
97
+ } catch (error) { findings.push(issue('error', category, id, dir, 'cube', `cube を読めません: ${error.message}`)); }
98
+ }
99
+ }
100
+ if (meta?.license?.attribution_required && !files.some(file => file.name === 'CREDIT.txt')) {
101
+ findings.push(issue('warning', category, id, dir, 'credit-missing', 'クレジット必須ですが CREDIT.txt がありません'));
102
+ }
103
+ if (meta?.tags?.includes('license:subscription')) {
104
+ findings.push(issue('warning', category, id, dir, 'subscription', 'サブスク素材です。契約が続いているか確認してください'));
105
+ }
106
+ }
107
+ if (project) {
108
+ const known = new Set(rows.map(row => `${row.category}/${row.id}`));
109
+ for (const ref of await readProjectReferences(project)) {
110
+ if (!known.has(`${ref.category}/${ref.id}`)) findings.push(issue('error', ref.category, ref.id, project, 'reference-missing', '参照の台帳にありますが置き場に素材がありません'));
111
+ }
112
+ }
113
+ const affected = new Set(findings.filter(row => row.level === 'error').map(row => `${row.category}/${row.id}`));
114
+ const warned = new Set(findings.filter(row => row.level === 'warning').map(row => `${row.category}/${row.id}`));
115
+ return { ok: rows.filter(row => !affected.has(`${row.category}/${row.id}`) && !warned.has(`${row.category}/${row.id}`)).length,
116
+ warnings: findings.filter(row => row.level === 'warning'), errors: findings.filter(row => row.level === 'error'), findings };
117
+ }
118
+
119
+ export async function projectCredits(project, env = process.env) {
120
+ if (!(await stat(project)).isDirectory()) throw new Error('プロジェクトがフォルダではありません');
121
+ const references = await readProjectReferences(project);
122
+ const candidates = [...references];
123
+ for (const category of ASSET_CATEGORIES) {
124
+ let ids = [];
125
+ try { ids = await readdir(path.join(project, 'assets', category)); } catch { continue; }
126
+ for (const id of ids) candidates.push({ category, id });
127
+ }
128
+ const seenAssets = new Set();
129
+ const seenTexts = new Set();
130
+ const credits = [];
131
+ for (const { category, id } of candidates) {
132
+ const key = `${category}/${id}`;
133
+ if (seenAssets.has(key) || !ASSET_CATEGORIES.includes(category) || !id || /[\\/]/.test(id) || id === '..') continue;
134
+ seenAssets.add(key);
135
+ const roots = [path.join(project, 'assets'), ...resolveAssetLibraryRoots(env).read];
136
+ for (const root of roots) {
137
+ const dir = path.join(root, category, id);
138
+ if (!existsSync(dir)) continue;
139
+ try {
140
+ const actualRoot = await realpath(root), actual = await realpath(dir);
141
+ if (!actual.startsWith(`${actualRoot}${path.sep}`) || !(await lstat(dir)).isDirectory()) continue;
142
+ const meta = JSON.parse(await readFile(path.join(dir, 'meta.json'), 'utf8'));
143
+ if (!meta?.license?.attribution_required) break;
144
+ const credit = (await readFile(path.join(dir, 'CREDIT.txt'), 'utf8')).split(/\r?\n/)[0].trim();
145
+ if (credit && !seenTexts.has(credit)) { credits.push(credit); seenTexts.add(credit); }
146
+ break;
147
+ } catch { /* try another library root */ }
148
+ }
149
+ }
150
+ return credits;
151
+ }
@@ -0,0 +1,56 @@
1
+ import { appendFile, mkdir, readFile, realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { resolveAkariHome } from './env.mjs';
4
+
5
+ export function usagePath(env = process.env) {
6
+ return path.join(resolveAkariHome(env), 'library-usage.jsonl');
7
+ }
8
+
9
+ export async function appendLibraryUsage({ category, id, project }, env = process.env) {
10
+ if (![category, id].every(value => typeof value === 'string' && value && !/[\\/]/.test(value))) {
11
+ throw new TypeError('素材の種類・名前が不正です');
12
+ }
13
+ const entry = { at: new Date().toISOString(), category, id, project: await realpath(project) };
14
+ const target = usagePath(env);
15
+ await mkdir(path.dirname(target), { recursive: true });
16
+ await appendFile(target, `${JSON.stringify(entry)}\n`, 'utf8');
17
+ return entry;
18
+ }
19
+
20
+ export function aggregateLibraryUsage(lines, isProject = () => true) {
21
+ const result = {};
22
+ for (const line of typeof lines === 'string' ? lines.split(/\r?\n/) : lines) {
23
+ let row;
24
+ try { row = typeof line === 'string' ? JSON.parse(line) : line; } catch { continue; }
25
+ if (!row || typeof row !== 'object' || typeof row.category !== 'string' || !row.category
26
+ || /[\\/]/.test(row.category) || typeof row.id !== 'string' || !row.id || /[\\/]/.test(row.id)
27
+ || typeof row.project !== 'string'
28
+ || !path.isAbsolute(row.project) || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at))
29
+ || !isProject(row.project)) continue;
30
+ const key = `${row.category}/${row.id}`;
31
+ const value = result[key] ??= { count: 0, lastUsedAt: '', projects: [] };
32
+ value.count++;
33
+ if (row.at > value.lastUsedAt) value.lastUsedAt = row.at;
34
+ if (!value.projects.includes(row.project)) value.projects.push(row.project);
35
+ }
36
+ for (const value of Object.values(result)) value.projects.sort();
37
+ return result;
38
+ }
39
+
40
+ export async function readLibraryUsage(env = process.env) {
41
+ let lines = '';
42
+ try { lines = await readFile(usagePath(env), 'utf8'); }
43
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
44
+ const projects = new Set();
45
+ for (const line of lines.split(/\r?\n/)) {
46
+ try {
47
+ const project = JSON.parse(line)?.project;
48
+ if (typeof project === 'string' && path.isAbsolute(project)) projects.add(project);
49
+ } catch { /* invalid journal line */ }
50
+ }
51
+ const existing = new Set();
52
+ await Promise.all([...projects].map(async project => {
53
+ try { if ((await stat(project)).isDirectory()) existing.add(project); } catch { /* removed project */ }
54
+ }));
55
+ return aggregateLibraryUsage(lines, project => existing.has(project));
56
+ }
@@ -42,19 +42,21 @@ const invalid = [
42
42
  ['add', '--apply', 'plan.json', '--typo'], ['add', '--apply', 'plan.json', 'extra'],
43
43
  ['add', '--apply'], ['add', '--apply', '--json'], ['add', '--apply', 'plan.json', '--plan'],
44
44
  ['add', 'file', '--plan', '-x'], ['list', 'extra'], ['sync', '--typo'], ['browse', 'extra'],
45
+ ['check', '--jsoon'], ['check', '--json', 'extra'], ['check', '--project'], ['check', '--json', '--json'],
46
+ ['credits'], ['credits', '--project'], ['credits', '--project', 'x', 'extra'], ['credits', '--jsoon'],
45
47
  ];
46
48
  for (const viaLauncher of [false, true]) {
47
49
  test(`${viaLauncher ? 'akari assets' : 'akari-assets'}: help and invalid arguments preserve every file and sha256`, async t => {
48
50
  const f = await fixture(t);
49
51
  const before = await manifest(f.temp);
50
52
  const cases = [
51
- ...['list', 'add', 'fetch', 'bundle', 'migrate', 'sync', 'browse'].flatMap(sub =>
53
+ ...['list', 'add', 'fetch', 'bundle', 'migrate', 'sync', 'browse', 'check', 'credits'].flatMap(sub =>
52
54
  ['--help', '-h'].map(flag => [[sub, flag], 0])),
53
55
  ...invalid.map(args => [args, 2]),
54
56
  ];
55
57
  for (const [args, status] of cases) {
56
58
  const result = spawnSync(process.execPath, viaLauncher ? [launcher, 'assets', ...args] : [cli, ...args],
57
- { env: f.env, encoding: 'utf8', timeout: 10000 });
59
+ { env: f.env, encoding: 'utf8', timeout: 30000 });
58
60
  assert.equal(result.status, status, `${args.join(' ')}: ${result.stderr}`);
59
61
  assert.match(result.stdout, /使い方: akari-assets/);
60
62
  assert.deepEqual(await manifest(f.temp), before, args.join(' '));
@@ -68,7 +70,7 @@ for (const viaLauncher of [false, true]) {
68
70
  const before = await manifest(f.temp);
69
71
  for (let run = 0; run < 2; run++) {
70
72
  const result = spawnSync(process.execPath, viaLauncher ? [launcher, 'assets', 'migrate'] : [cli, 'migrate'],
71
- { env: f.env, encoding: 'utf8', timeout: 10000 });
73
+ { env: f.env, encoding: 'utf8', timeout: 30000 });
72
74
  assert.equal(result.status, 0, result.stderr);
73
75
  const data = JSON.parse(result.stdout);
74
76
  assert.match(data.skippedReason, /same location/);