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 +1 -1
- package/src/narration-command.mjs +136 -11
- package/vendor/docs/contract-2026-08-02-preview-parity.md +2 -2
- package/vendor/docs/contract-2026-08-09-transform-keyframes-v0.md +2 -2
- package/vendor/docs/contract-2026-08-30-motion-and-keyframes-v0.md +2 -2
- package/vendor/docs/contract-2026-09-13-generation-v0.md +2 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/asset-resolver/bin/akari-assets.mjs +23 -1
- package/vendor/packages/asset-resolver/src/library-check.mjs +151 -0
- package/vendor/packages/asset-resolver/src/library-usage.mjs +56 -0
- package/vendor/packages/asset-resolver/test/cli-guards.test.mjs +5 -3
- package/vendor/packages/asset-resolver/test/library-check.test.mjs +83 -0
- package/vendor/packages/asset-resolver/test/library-usage.test.mjs +25 -0
- package/vendor/packages/edit-store/lib/caption-display.js +60 -9
- package/vendor/packages/edit-store/lib/caption-store.js +50 -1
- package/vendor/packages/edit-store/lib/caption-words-rederive.d.ts +20 -0
- package/vendor/packages/edit-store/lib/caption-words-rederive.js +295 -15
- package/vendor/packages/edit-store/lib/webview-kernel.js +5 -4
- package/vendor/skills/generate-narration/engines.md +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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)
|
|
68
|
-
>
|
|
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)
|
|
27
|
-
>
|
|
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)
|
|
73
|
-
>
|
|
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.
|
|
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:
|
|
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:
|
|
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/);
|