akari-video 1.0.2 → 1.1.0-beta.1

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/self-update.mjs +11 -1
  3. package/src/update-check.mjs +34 -29
  4. package/src/version.mjs +23 -0
  5. package/vendor/.akari-capability-sources.json +1 -0
  6. package/vendor/docs/contract-2026-09-02-audio-envelope-v1.md +9 -0
  7. package/vendor/docs/contract-2026-10-06-timeline-ripple-v0.md +33 -0
  8. package/vendor/packages/akari-launcher/package.json +1 -1
  9. package/vendor/packages/asset-resolver/src/place-library-asset.mjs +54 -0
  10. package/vendor/packages/asset-resolver/src/project-reference-listing.mjs +22 -0
  11. package/vendor/packages/asset-resolver/src/project-references.mjs +24 -1
  12. package/vendor/packages/asset-resolver/src/resolve-async-library.mjs +113 -0
  13. package/vendor/packages/asset-resolver/src/resolve.mjs +40 -13
  14. package/vendor/packages/asset-resolver/src/shell-reference.mjs +27 -14
  15. package/vendor/packages/asset-resolver/test/library-fetch-no-spawn-reference.test.mjs +61 -0
  16. package/vendor/packages/asset-resolver/test/resolve-legacy-meta-tier.test.mjs +52 -0
  17. package/vendor/packages/edit-lint/src/engine-capabilities.json +2 -0
  18. package/vendor/packages/edit-lint/src/lint/edit-v2.mjs +10 -0
  19. package/vendor/packages/edit-store/lib/caption-store.d.ts +6 -0
  20. package/vendor/packages/edit-store/lib/caption-store.js +3 -2
  21. package/vendor/packages/edit-store/lib/cut-ranges.js +12 -50
  22. package/vendor/packages/edit-store/lib/edit-v2.d.ts +9 -0
  23. package/vendor/packages/edit-store/lib/edit-v2.js +12 -2
  24. package/vendor/packages/edit-store/lib/envelope.d.ts +10 -0
  25. package/vendor/packages/edit-store/lib/envelope.js +95 -0
  26. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  27. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +3 -1
  28. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  29. package/vendor/packages/edit-store/lib/index.js +1 -0
  30. package/vendor/packages/edit-store/lib/migrate/index.js +15 -4
  31. package/vendor/packages/edit-store/lib/ripple.d.ts +42 -0
  32. package/vendor/packages/edit-store/lib/ripple.js +438 -0
  33. package/vendor/packages/edit-store/lib/shape-markup-v1.js +17 -13
  34. package/vendor/packages/edit-store/lib/shape-markup.js +5 -2
  35. package/vendor/packages/edit-store/lib/webview-kernel.js +118 -16
  36. package/vendor/packages/overlay-runtime/src/interaction.css +7 -0
  37. package/vendor/packages/overlay-runtime/src/interaction.js +68 -5
  38. package/vendor/packages/schemas/edit.schema.json +11 -1
  39. package/vendor/packages/schemas/engine-capabilities.json +2 -0
  40. package/vendor/packages/schemas/test/audio-fade-shape.test.mjs +31 -0
  41. package/vendor/packages/schemas/test/edit-v2-schema.test.mjs +12 -0
  42. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.2",
3
+ "version": "1.1.0-beta.1",
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": {
@@ -4,7 +4,8 @@ import { spawnSync } from 'node:child_process';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import { resolveAkariHome } from './update-check.mjs';
7
+ import { readUpdateChannel, resolveAkariHome } from './update-check.mjs';
8
+ import { compareVersions } from './version.mjs';
8
9
 
9
10
  /**
10
11
  * `akari update` の実適用(update-and-versioning 契約(内部リポ)§11):
@@ -202,6 +203,15 @@ export async function stageSelfUpdate({
202
203
  }
203
204
 
204
205
  const version = feed.product;
206
+ if (readUpdateChannel(env) === 'stable' && version?.includes('-')) {
207
+ log('安定版だけを受け取る設定です。');
208
+ return { ok: false, reason: 'channel-mismatch' };
209
+ }
210
+ const installedVersion = readVersionAt(resolveAppDir(env));
211
+ if (installedVersion && compareVersions(version, installedVersion) < 0) {
212
+ log('現在より古い版は適用できません。');
213
+ return { ok: false, reason: 'older-version' };
214
+ }
205
215
  const stagingRoot = resolveStagingRoot(env);
206
216
  const stagingDir = resolveStagingDir(env, version);
207
217
  const downloadPath = join(stagingRoot, `.download-${version}.tar.gz`);
@@ -5,6 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { isRunningFromAppDir, resolveStagingDir, stageSelfUpdate, swapStagedApp } from './self-update.mjs';
8
+ import { compareVersions } from './version.mjs';
9
+ export { compareVersions } from './version.mjs';
8
10
 
9
11
  /**
10
12
  * CLI(と、同じ契約に従うシェル)の更新検知・通知の共通ロジック
@@ -35,6 +37,7 @@ import { isRunningFromAppDir, resolveStagingDir, stageSelfUpdate, swapStagedApp
35
37
  */
36
38
 
37
39
  export const DEFAULT_UPDATE_FEED_URL = 'https://github.com/AkariLabs/akari-video/releases/download/updates/latest.json';
40
+ export const PRERELEASE_UPDATE_FEED_URL = 'https://github.com/AkariLabs/akari-video/releases/download/updates/prerelease.json';
38
41
 
39
42
  const CACHE_SCHEMA = 1;
40
43
  const FETCH_TIMEOUT_MS = 5000;
@@ -42,8 +45,18 @@ const FETCH_TIMEOUT_MS = 5000;
42
45
  const THIS_FILE = fileURLToPath(import.meta.url);
43
46
  const PACKAGE_ROOT = dirname(dirname(THIS_FILE));
44
47
 
45
- export function resolveFeedUrl(env = process.env) {
46
- return env.AKARI_UPDATE_FEED_URL || DEFAULT_UPDATE_FEED_URL;
48
+ export function resolveUpdateChannel(value) {
49
+ return value === 'prerelease' ? 'prerelease' : 'stable';
50
+ }
51
+
52
+ export function readUpdateChannel(env = process.env) {
53
+ try { return resolveUpdateChannel(JSON.parse(readFileSync(join(resolveAkariHome(env), 'update-preferences.json'), 'utf8')).channel); }
54
+ catch { return 'stable'; }
55
+ }
56
+
57
+ // 明示 URL はテスト・運用の完全上書き。チャンネルは評価側でも独立に検査する。
58
+ export function resolveFeedUrl(env = process.env, channel = readUpdateChannel(env)) {
59
+ return env.AKARI_UPDATE_FEED_URL || (channel === 'prerelease' ? PRERELEASE_UPDATE_FEED_URL : DEFAULT_UPDATE_FEED_URL);
47
60
  }
48
61
 
49
62
  /** `~/.akari`(既定)または `AKARI_HOME`(テスト用にルートを差し替え可能)。 */
@@ -112,26 +125,6 @@ export function resolveInstalledVersionInfo({ env = process.env, cliVersion = re
112
125
  };
113
126
  }
114
127
 
115
- /** "major.minor.patch" の先頭 3 要素だけを数値比較する(prerelease 考慮不要 — 契約 D4: stable のみ)。 */
116
- export function compareVersions(a, b) {
117
- const pa = parseVersionTriplet(a);
118
- const pb = parseVersionTriplet(b);
119
- if (!pa || !pb) {
120
- return 0;
121
- }
122
- for (let i = 0; i < 3; i++) {
123
- if (pa[i] !== pb[i]) {
124
- return pa[i] < pb[i] ? -1 : 1;
125
- }
126
- }
127
- return 0;
128
- }
129
-
130
- function parseVersionTriplet(value) {
131
- const match = typeof value === 'string' ? value.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
132
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
133
- }
134
-
135
128
  /** `cache.feed` が最低限の形をしているか(壊れたフィードを弾く)。 */
136
129
  export function isValidFeedShape(feed) {
137
130
  return !!feed && typeof feed === 'object' && typeof feed.schema === 'number' && typeof feed.product === 'string';
@@ -141,7 +134,13 @@ export function isValidFeedShape(feed) {
141
134
  export function readCacheSync(cachePath) {
142
135
  try {
143
136
  const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
144
- return parsed && typeof parsed === 'object' ? parsed : null;
137
+ if (!parsed || typeof parsed !== 'object') return null;
138
+ // cli.mjs もこの関数でキャッシュを読む。設定切替直後の手動 update を含めて遮断。
139
+ let channel = 'stable';
140
+ try { channel = resolveUpdateChannel(JSON.parse(readFileSync(join(dirname(cachePath), 'update-preferences.json'), 'utf8')).channel); }
141
+ catch { /* 未設定は stable */ }
142
+ return channel === 'stable' && parsed.feed?.product?.includes('-')
143
+ ? { ...parsed, feed: null, staged: undefined } : parsed;
145
144
  } catch {
146
145
  return null;
147
146
  }
@@ -155,7 +154,7 @@ function writeCacheSync(cachePath, cache) {
155
154
  /**
156
155
  * キャッシュと現在版から、新版通知を出すべきかを判定する(同期・純粋関数・I/O なし)。
157
156
  */
158
- export function evaluateUpdateStatus({ currentVersion, cache, cliVersion = currentVersion, appVersion = null, source = 'cli-fallback', installRefStatus, installRefPath, installRefNeedsRepair, managedApp, mismatch = false }) {
157
+ export function evaluateUpdateStatus({ currentVersion, cache, channel = 'stable', feedUrl, cliVersion = currentVersion, appVersion = null, source = 'cli-fallback', installRefStatus, installRefPath, installRefNeedsRepair, managedApp, mismatch = false }) {
159
158
  const versionDetails = {
160
159
  currentVersion,
161
160
  cliVersion,
@@ -168,7 +167,8 @@ export function evaluateUpdateStatus({ currentVersion, cache, cliVersion = curre
168
167
  mismatch
169
168
  };
170
169
  const feed = cache?.feed;
171
- if (!isValidFeedShape(feed)) {
170
+ if (!isValidFeedShape(feed) || (channel === 'stable' && /-/.test(feed.product))
171
+ || (cache?.feed_url && feedUrl && cache.feed_url !== feedUrl)) {
172
172
  return { available: false, ...versionDetails };
173
173
  }
174
174
  const latest = feed.product;
@@ -200,7 +200,8 @@ export function checkForUpdateSync({ currentVersion, versionInfo, env = process.
200
200
  ?? (currentVersion === undefined
201
201
  ? resolveInstalledVersionInfo({ env })
202
202
  : { cliVersion: currentVersion, appVersion: null, currentVersion, source: 'cli-fallback', mismatch: false });
203
- return evaluateUpdateStatus({ ...versions, currentVersion: currentVersion ?? versions.currentVersion, cache });
203
+ return evaluateUpdateStatus({ ...versions, currentVersion: currentVersion ?? versions.currentVersion, cache,
204
+ channel: readUpdateChannel(env), feedUrl: resolveFeedUrl(env) });
204
205
  }
205
206
 
206
207
  /** 「この版の通知を今後出さない」を記録する(同期・ローカル I/O のみ)。 */
@@ -234,7 +235,8 @@ export async function maybeStageInBackground({ env = process.env, feed, fetchImp
234
235
  if (!appComponent?.url || !appComponent?.sha256) {
235
236
  return null;
236
237
  }
237
- if (compareVersions(feed.product, resolveInstalledVersionInfo({ env }).currentVersion) <= 0) {
238
+ if ((readUpdateChannel(env) === 'stable' && feed.product.includes('-'))
239
+ || compareVersions(feed.product, resolveInstalledVersionInfo({ env }).currentVersion) <= 0) {
238
240
  return null;
239
241
  }
240
242
  // npm 側 CLI と本体が分離していても、install-ref があれば管理対象の本体を更新できる。
@@ -262,7 +264,7 @@ export async function refreshUpdateFeed({ env = process.env, fetchImpl = globalT
262
264
  return;
263
265
  }
264
266
  const feed = await response.json();
265
- if (!isValidFeedShape(feed)) {
267
+ if (!isValidFeedShape(feed) || (readUpdateChannel(env) === 'stable' && feed.product.includes('-'))) {
266
268
  return null;
267
269
  }
268
270
  const existing = readCacheSync(cachePath);
@@ -270,6 +272,7 @@ export async function refreshUpdateFeed({ env = process.env, fetchImpl = globalT
270
272
  schema: CACHE_SCHEMA,
271
273
  fetched_at: new Date().toISOString(),
272
274
  feed,
275
+ feed_url: feedUrl,
273
276
  dismissed: existing?.dismissed ?? {}
274
277
  };
275
278
  writeCacheSync(cachePath, next);
@@ -347,7 +350,9 @@ export function maybeApplyPendingUpdateOnLaunch({ env = process.env, log = () =>
347
350
  const cache = readCacheSync(cachePath);
348
351
  const staged = cache?.staged;
349
352
  const feed = cache?.feed;
350
- if (!staged?.version || !isValidFeedShape(feed) || staged.version !== feed.product) {
353
+ if (!staged?.version || !isValidFeedShape(feed) || staged.version !== feed.product
354
+ || (readUpdateChannel(env) === 'stable' && feed.product.includes('-'))
355
+ || (cache.feed_url && cache.feed_url !== resolveFeedUrl(env))) {
351
356
  return { applied: false, reason: 'no-matching-staged-update' };
352
357
  }
353
358
 
@@ -0,0 +1,23 @@
1
+ /** npm 依存ゼロの launcher 用 semver 比較。build metadata は順序に影響しない。 */
2
+ export function compareVersions(a, b) {
3
+ const parse = value => {
4
+ const match = typeof value === 'string' ? value.trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/) : null;
5
+ if (!match) return null;
6
+ const pre = match[4]?.split('.') ?? null;
7
+ if (pre?.some(id => /^0\d+$/.test(id))) return null;
8
+ return { core: match.slice(1, 4).map(Number), pre };
9
+ };
10
+ const left = parse(a), right = parse(b);
11
+ if (!left || !right) return 0;
12
+ for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return Math.sign(left.core[i] - right.core[i]);
13
+ if (!left.pre || !right.pre) return left.pre === right.pre ? 0 : left.pre ? -1 : 1;
14
+ for (let i = 0; i < Math.min(left.pre.length, right.pre.length); i++) {
15
+ const x = left.pre[i], y = right.pre[i];
16
+ if (x === y) continue;
17
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
18
+ if (xn && yn) return Math.sign(BigInt(x) > BigInt(y) ? 1 : -1);
19
+ if (xn !== yn) return xn ? -1 : 1;
20
+ return x < y ? -1 : 1;
21
+ }
22
+ return Math.sign(left.pre.length - right.pre.length);
23
+ }
@@ -82,6 +82,7 @@
82
82
  "docs/contract-2026-10-02-fragment-font-subset-v0.md",
83
83
  "docs/contract-2026-10-02-textstyle-v1-rich.md",
84
84
  "docs/contract-2026-10-04-asset-tier-v1.md",
85
+ "docs/contract-2026-10-06-timeline-ripple-v0.md",
85
86
  "packages/akari-launcher/package.json",
86
87
  "packages/akari-launcher/README.md",
87
88
  "packages/akari-tools/package.json",
@@ -89,3 +89,12 @@ schema と reader は型・範囲・時刻順を error にする。lint は実
89
89
  | 許容する差 | デコーダとサンプル境界 | デコーダとサンプル境界 |
90
90
 
91
91
  afftdn、loudnorm、true peak guard は最終マスターだけの責務であり、プレビューには実装しない。
92
+
93
+ ## 7. フェードの形
94
+
95
+ 音声アイテムは `fade_in_shape` / `fade_out_shape` を任意に持つ。省略時は `linear` とし、
96
+ 既存のフェード秒数、クランプ規則、フィルタ文字列を維持する。進行率 `p`(0〜1)に対する
97
+ ゲイン係数は `linear` = `p`、`equal_power` = `sin(pπ/2)`、
98
+ `s_curve` = `(1−cos(pπ))/2`、`slow` = `p²` とする。フェードアウトは時間方向を反転する。
99
+ 書き出しの `afade` 曲線は順に `tri`、`qsin`、`hsin`、`qua` を使う。
100
+ プレビューではフェード区間を少なくとも 16 区間の折れ線で近似する。
@@ -0,0 +1,33 @@
1
+ ---
2
+ lifecycle: implemented
3
+ created: 2026-10-06
4
+ updated: 2026-10-06
5
+ ---
6
+
7
+ # タイムラインの範囲操作と詰めカーネル v0
8
+
9
+ 実装モジュールは `packages/edit-store/src/ripple.ts`。`cut-ranges.ts` はこのカーネルを呼び出す。edit.json v2 の時刻は整数フレーム。`tracks[]` の配列順は合成順として維持する。範囲編集関数は入力を変更せず `{ edit, changed, removedFrames?, blocked?, reason? }` を返す。`changed: false` では `reason` に操作不能の理由を入れる。`setTrackRippleMode` は新しい edit を直接返し、`findGapAt` と `editPoints` は読み取り値を返す。
10
+
11
+ ## トラックの札
12
+
13
+ `tracks[]` に任意の `target` / `sync` 真偽値を保存する。`resolveTrackRippleMode(track)` は `target: true` を `cut`、`target: false, sync: true` を `shift`、`target: false` とそれ以外の組を `fixed` とする。両キー省略時は visual が `cut`、audio は 1 件以上の全アイテムが `role: "bgm"` なら `fixed`、それ以外は `cut`。`sync` だけ指定したときは true が `shift`、false が `fixed`。`setTrackRippleMode` は `cut = (true,true)`、`shift = (false,true)`、`fixed = (false,false)` を保存する。
14
+
15
+ 操作ごとの `modeOverride` は一時指定で、`lockedTrackIds` は常に固定として優先する。固定トラックは範囲操作で変えない。選択アイテムを明示して消す場合はそのトラックを削除対象とするが、ロック中なら操作全体を拒否する。
16
+
17
+ | 関数 | 内容 |
18
+ |---|---|
19
+ | `splitAtFrame` | 再生ヘッドをまたぐ cut トラックの media / html / telop / filter / 独立音声を分割。`itemIds` または `trackIds` で絞れる。リンクした映像・音声は同時に分割する。リンク相手がロック行にある場合、または左右どちらかが `ceil(fps × 0.15)` フレーム未満なら、全体を拒否して `changed: false` と `reason: "リンク相手が固定中か、片側が最小尺未満です"` を返す。 |
20
+ | `liftRange` | cut トラックの範囲内を取り除き、隙間を残す。 |
21
+ | `extractRange` | cut トラックの範囲内を取り除き、後続を範囲長だけ前へ送る。shift トラックは中身を取り除かず、範囲終端以降に始まるアイテムだけを前へ送る。 |
22
+ | `rippleDeleteItems` | 選択アイテムの区間を詰める。他トラックは中身を消さずに後続を送る。リンク相手も既定で対象とし、`oneSide` で片側だけにできる。離れた選択は右から処理する。 |
23
+ | `findGapAt` / `closeGapAt` | 指定トラックでフレームを含む先頭またはアイテム間の隙間を見つけ、その長さだけ後続を送る。末尾の空きは対象外。 |
24
+ | `rippleTrimToPlayhead` | 最も近い前後の編集点から再生ヘッドまでを `extractRange` する。編集点が無ければ理由を返す。 |
25
+ | `editPoints` | cut トラックのアイテム端を昇順・重複なしで返す。 |
26
+ | `compactTrackGaps` | cut の visual トラックにある media だけを配列順に詰める。`fromItemId` があればそのアイテムの後続だけを詰める。既定では anchor 付きアイテムを直接動かさない。 |
27
+ | `applyCutRanges` | 素材秒で重なる media を選び、共通の区間除去部品で切除する。触った visual トラックの media だけを `at = 0` から配列順に詰める旧挙動を保つ。 |
28
+
29
+ shift は前のアイテムの終端より前に入らない。必要な移動量を確保できなかったアイテムの id を `blocked` に返し、重なりを作らない。`anchor` を持つアイテムは shift で直接動かさない。cut トラックなら範囲内の削除対象にはなる。位置の再解決は `refreshItemAnchors` の責務とする。`applyCutRanges` は旧挙動を維持するため、`compactTrackGaps` に `includeAnchored: true` を渡して触ったトラックの media を詰める。
30
+
31
+ 分割片の `source.in/out` は、素材秒をアイテムのフレーム比率(speed 込み)で按分する。音声の `keyframes[].gain_db` と映像の数値キーフレームは境界点を両片に入れ、ローカルフレーム時刻へ振り分ける。境界値は区間の終点側キーの `easing` を `easingProgress` で評価する。文字列指定は全プロパティ、Record 指定はプロパティ別(例: `gain_db`、`opacity`、`transform.x`)に適用し、左片の新しい境界点にも終点側の easing を引き継ぐ。線形区間の全フレーム値は分割前と一致する。非線形区間は境界値を一致させるが、分割後の区間内部の曲線は近似になる。`fade_in` は左片、`fade_out` は右片だけに残し、ダッキングなど他の属性は両片へ複製する。リンク音声の右片は映像の右片へ `link` を付け替える。
32
+
33
+ 字幕本文は `captions.json` のソース秒が正本であり、ここでは変更しない。切除後の表示位置は `timeline-map` の `sourceToOutput` で追従する。通常の Delete は隙間を残す。詰める処理は明示した操作でだけ行う。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.2",
3
+ "version": "1.1.0-beta.1",
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": [
@@ -0,0 +1,54 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
3
+ import { recordProjectReference } from './project-references.mjs';
4
+ import { appendLibraryUsage } from './library-usage.mjs';
5
+ import { ASSET_CATEGORIES } from './library.mjs';
6
+ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
7
+
8
+ const within = (root, target) => {
9
+ const rel = relative(root, target);
10
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
11
+ };
12
+
13
+ export async function placeLibraryAsset(source, projectPath, env = process.env) {
14
+ if (!source || !ASSET_CATEGORIES.includes(source.category)
15
+ || typeof source.id !== 'string' || !source.id || source.id === '.'
16
+ || source.id.includes('..') || source.id.includes('/') || source.id.includes(String.fromCharCode(92))
17
+ || typeof source.libraryDir !== 'string' || !isAbsolute(source.libraryDir)) {
18
+ throw new Error('素材の種類・名前・置き場が不正です');
19
+ }
20
+ const actual = await realpath(source.libraryDir);
21
+ if (basename(actual) !== source.id || basename(dirname(actual)) !== source.category
22
+ || !(await stat(actual)).isDirectory()) {
23
+ throw new Error('素材の置き場と種類・名前が一致しません');
24
+ }
25
+ let allowed = false;
26
+ for (const root of resolveAssetLibraryRoots(env).read) {
27
+ try { if (within(await realpath(root), actual)) allowed = true; }
28
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
29
+ }
30
+ if (!allowed) throw new Error('素材がライブラリの置き場の外にあります');
31
+ const project = await realpath(projectPath);
32
+ if (!(await stat(project)).isDirectory()) throw new Error('プロジェクトがフォルダではありません');
33
+ const destination = join(project, 'assets', source.category, source.id);
34
+ let parent = dirname(destination);
35
+ let actualDestination;
36
+ while (true) {
37
+ try {
38
+ const actualParent = await realpath(parent);
39
+ if (!within(project, actualParent)) throw new Error('配置先がプロジェクトの外にあります');
40
+ actualDestination = join(actualParent, relative(parent, destination));
41
+ break;
42
+ } catch (error) {
43
+ if (error.code !== 'ENOENT') throw error;
44
+ parent = dirname(parent);
45
+ }
46
+ }
47
+ if (within(actualDestination, actual) || within(actual, actualDestination)) {
48
+ throw new Error('素材の大元と重なる配置はできません');
49
+ }
50
+ await recordProjectReference(project, { category: source.category, id: source.id });
51
+ await appendLibraryUsage({ category: source.category, id: source.id, project });
52
+ return { success: true, projectAssetPath: join(projectPath, 'assets', source.category, source.id),
53
+ reference: true, libraryDir: actual };
54
+ }
@@ -0,0 +1,22 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { listProjectReferenceAssets } from './shell-reference.mjs';
3
+ import { sourceFields } from './library.mjs';
4
+ import { readCatalogCache } from './catalog.mjs';
5
+ import { resolveCatalogSource } from './env.mjs';
6
+
7
+ export async function listProjectAssetReferences(project, env = process.env) {
8
+ let catalog = await readCatalogCache(env);
9
+ const source = resolveCatalogSource(env);
10
+ if (source.kind === 'file') { try { catalog = JSON.parse(await readFile(source.value, 'utf8')); } catch {} }
11
+ const entries = await listProjectReferenceAssets(project, env);
12
+ for (const entry of entries) {
13
+ let meta;
14
+ try { meta = JSON.parse(await readFile(entry.files.find(file => file.name === 'meta.json').path, 'utf8')); } catch {}
15
+ entry.tags = Array.isArray(meta?.tags) ? meta.tags.filter(tag => typeof tag === 'string') : [];
16
+ const known = catalog?.items?.find(item => item.id === entry.id && item.category === entry.category);
17
+ entry.title = typeof meta?.title === 'string' ? meta.title : typeof known?.title === 'string' ? known.title : entry.id;
18
+ if (!meta && known) entry.tags = Array.isArray(known.tags) ? known.tags.filter(tag => typeof tag === 'string') : [];
19
+ if (meta || known) entry.sourceKind = sourceFields(meta ?? known, !!known).sourceKind;
20
+ }
21
+ return entries;
22
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { lstatSync, realpathSync } from 'node:fs';
3
- import fsPromises, { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import fsPromises, { lstat, realpath, mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import path from 'node:path';
6
6
 
@@ -219,3 +219,26 @@ export function resolveLibraryFallback({ declaredPath, references, akariAssetsDi
219
219
  return null;
220
220
  }
221
221
  }
222
+
223
+ export async function resolveLibraryFallbackAsync({ declaredPath, references, akariAssetsDir, rootRealpaths }) {
224
+ const parsed = parseDeclaredAssetPath(declaredPath);
225
+ if (!parsed || typeof akariAssetsDir !== 'string' || akariAssetsDir.length === 0) return null;
226
+ if (!normalizeReferences(references).some(
227
+ entry => entry.category === parsed.category && entry.id === parsed.id,
228
+ )) return null;
229
+ const lexicalRoot = path.resolve(akariAssetsDir);
230
+ const lexicalTarget = path.resolve(lexicalRoot, parsed.category, parsed.id, ...parsed.rest);
231
+ if (!isWithin(lexicalRoot, lexicalTarget)) return null;
232
+ try {
233
+ let actualRoot = rootRealpaths?.get(lexicalRoot);
234
+ if (!actualRoot) {
235
+ actualRoot = realpath(lexicalRoot);
236
+ rootRealpaths?.set(lexicalRoot, actualRoot);
237
+ }
238
+ const [root, target] = await Promise.all([actualRoot, realpath(lexicalTarget)]);
239
+ if (!isWithin(root, target) || !(await lstat(target)).isFile()) return null;
240
+ return target;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
@@ -0,0 +1,113 @@
1
+ import { readdir, stat, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
4
+ import { assetTier } from './tier.mjs';
5
+ import { loadCatalog, readCatalogCache } from './catalog.mjs';
6
+ import { mergeInstalledItems, INSTALLED_ASSETS_SCHEMA } from './installed.mjs';
7
+ import { resolveCatalogSource } from './env.mjs';
8
+
9
+ export async function cachedAssetDirAsync(env, category, id) {
10
+ for (const root of resolveAssetLibraryRoots(env).read) {
11
+ const dir = path.resolve(root, category, id);
12
+ try {
13
+ if ((await stat(dir)).isDirectory() && (await readdir(dir)).length) return dir;
14
+ } catch { /* next root */ }
15
+ }
16
+ return null;
17
+ }
18
+
19
+ const safe = value => typeof value === 'string' && value.length > 0
20
+ && value !== '.' && value !== '..' && !/[\\/]/.test(value);
21
+
22
+ function within(root, ...parts) {
23
+ const base = path.resolve(root);
24
+ const candidate = path.resolve(base, ...parts);
25
+ if (candidate !== base && !candidate.startsWith(`${base}${path.sep}`)) {
26
+ throw new Error(`導入済み素材のパスがパック外を指しています: ${parts.join('/')}`);
27
+ }
28
+ return candidate;
29
+ }
30
+
31
+ async function installedItemsAsync(env) {
32
+ const roots = resolveAssetLibraryRoots(env).read;
33
+ const byId = new Map();
34
+ for (const libraryRoot of [...roots].reverse()) {
35
+ const indexPath = path.join(libraryRoot, 'installed.json');
36
+ let index;
37
+ try { index = JSON.parse(await readFile(indexPath, 'utf8')); }
38
+ catch (error) {
39
+ if (error?.code === 'ENOENT') continue;
40
+ throw new Error(`導入済み素材索引を読めません: ${indexPath}: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ if (index?.schema !== INSTALLED_ASSETS_SCHEMA
43
+ || !index.packs || typeof index.packs !== 'object' || Array.isArray(index.packs)) {
44
+ throw new Error(`導入済み素材索引の形式が想定と違います: ${indexPath}`);
45
+ }
46
+ for (const [packId, pack] of Object.entries(index.packs)) {
47
+ if (!safe(packId) || !pack || typeof pack.root !== 'string' || !path.isAbsolute(pack.root)
48
+ || (typeof pack.version !== 'string' && typeof pack.version !== 'number')
49
+ || typeof pack.installedAt !== 'string' || !pack.installedAt || !Array.isArray(pack.items)) {
50
+ throw new Error(`導入済み素材索引に不正な pack があります: ${packId}`);
51
+ }
52
+ const originalRoot = roots.find(root => {
53
+ try { within(path.join(root, 'store', packId), pack.root); return true; } catch { return false; }
54
+ });
55
+ if (!originalRoot) throw new Error(`導入済み素材の root がパック外を指しています: ${pack.root}`);
56
+ const relativeRoot = path.relative(originalRoot, pack.root);
57
+ const readRoots = roots.map(root => path.join(root, relativeRoot));
58
+ for (const item of pack.items) {
59
+ if (!item || !safe(item.id) || typeof item.title !== 'string' || !item.title
60
+ || typeof item.path !== 'string' || !item.path || item.version == null) {
61
+ throw new Error(`導入済み素材索引に不正な item があります: ${packId}`);
62
+ }
63
+ if (!Array.isArray(item.files) || item.files.length === 0) {
64
+ throw new Error(`導入済み素材索引の item に files[] がありません: ${item.id}`);
65
+ }
66
+ const itemRoots = readRoots.map(root => within(root, item.path));
67
+ let metaTier;
68
+ for (const root of itemRoots) {
69
+ try {
70
+ const meta = JSON.parse(await readFile(path.join(root, 'meta.json'), 'utf8'));
71
+ if (Object.hasOwn(meta, 'tier') || Object.hasOwn(meta, 'price')) metaTier = assetTier(meta);
72
+ break;
73
+ } catch (error) { if (error?.code !== 'ENOENT') break; }
74
+ }
75
+ const categoryParts = item.path.replaceAll('\\', '/').split('/').filter(Boolean);
76
+ const category = categoryParts[0] === 'assets' && safe(categoryParts[1]) ? categoryParts[1] : 'pack';
77
+ const files = await Promise.all(item.files.map(async file => {
78
+ if (!file || typeof file.path !== 'string' || !file.path
79
+ || !Number.isInteger(file.bytes) || file.bytes < 0
80
+ || typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256)) {
81
+ throw new Error(`導入済み素材索引の files[] が不正です: ${item.id}`);
82
+ }
83
+ const candidates = itemRoots.map(root => within(root, file.path));
84
+ let localPath = candidates[0];
85
+ for (const candidate of candidates) {
86
+ if (await stat(candidate).then(() => true, () => false)) { localPath = candidate; break; }
87
+ }
88
+ return { name: file.path, local_path: localPath, sha256: file.sha256, bytes: file.bytes };
89
+ }));
90
+ byId.set(item.id, { id: item.id, title: item.title, category, version: item.version,
91
+ price: 0, ...((Object.hasOwn(item, 'tier') ? assetTier(item) : metaTier)
92
+ ? { tier: Object.hasOwn(item, 'tier') ? assetTier(item) : metaTier } : {}),
93
+ source: 'installed', files });
94
+ }
95
+ }
96
+ }
97
+ return [...byId.values()];
98
+ }
99
+
100
+ export async function loadCatalogForResolveAsync(ref, { env = process.env, fetchImpl = fetch, timeouts } = {}) {
101
+ const matches = typeof ref === 'string' && ref.includes('/')
102
+ ? item => `${item.category}/${item.id}` === ref : item => item.id === ref;
103
+ const installedItems = await installedItemsAsync(env);
104
+ if (installedItems.some(matches)) {
105
+ return { schema: 'akari-assets-catalog/v0', version: null, base: null, items: installedItems };
106
+ }
107
+ if (resolveCatalogSource(env).kind === 'url') {
108
+ const cached = await readCatalogCache(env);
109
+ if (cached && cached.items.some(matches)) return mergeInstalledItems(cached, installedItems);
110
+ }
111
+ const catalog = await loadCatalog({ env, fetchImpl, timeouts, fallbackToCache: false, includeInstalled: false });
112
+ return mergeInstalledItems(catalog, installedItems);
113
+ }
@@ -11,19 +11,18 @@ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
11
11
  // なら resolvePaidZip() が `/api/store/v1/download/<id>` から zip を取得し、展開 →
12
12
  // checksums.txt 検証(paid-zip.mjs)→ 同じ validate-asset / 原子的 move の経路に合流する。
13
13
 
14
- import { spawnSync } from 'node:child_process';
15
- import { constants, existsSync, realpathSync } from 'node:fs';
16
- import { cp, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
14
+ import { spawn } from 'node:child_process';
15
+ import { constants } from 'node:fs';
16
+ import { cp, mkdir, mkdtemp, readFile, rename, rm, realpath, stat, writeFile } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
  import { format } from 'node:util';
20
- import { loadCatalogForResolve } from './catalog.mjs';
20
+ import { loadCatalogForResolveAsync, cachedAssetDirAsync } from './resolve-async-library.mjs';
21
21
  import { resolveEffectiveBase } from './env.mjs';
22
22
  import { AssetResolverError } from './errors.mjs';
23
23
  import { fetchEntitlements, readStoreCredentials } from './entitlements.mjs';
24
24
  import { materialize, resolveFileLocation } from './fetch-file.mjs';
25
25
  import { sha256File } from './hash.mjs';
26
- import { cachedAssetDir, localAssetDir } from './library.mjs';
27
26
  import { downloadPaidZip, extractZip, verifyPaidZipContents } from './paid-zip.mjs';
28
27
  import { recordProjectReference } from './project-references.mjs';
29
28
  import { assetTier, isAssetEntitled } from './tier.mjs';
@@ -81,7 +80,15 @@ async function validateAsset(assetDir) {
81
80
  return await run;
82
81
  } catch {
83
82
  // Module loading failures are outside the validator's ordinary NG result.
84
- return spawnSync(process.execPath, [VALIDATE_ASSET_SCRIPT, assetDir], { encoding: 'utf8' });
83
+ return new Promise((resolveResult, reject) => {
84
+ const child = spawn(process.execPath, [VALIDATE_ASSET_SCRIPT, assetDir]);
85
+ let stdout = '';
86
+ let stderr = '';
87
+ child.stdout.on('data', chunk => { stdout += chunk; });
88
+ child.stderr.on('data', chunk => { stderr += chunk; });
89
+ child.on('error', reject);
90
+ child.on('close', status => resolveResult({ status, stdout, stderr }));
91
+ });
85
92
  }
86
93
  }
87
94
 
@@ -110,7 +117,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
110
117
  // 素材箱側が「meta.json を含むディレクトリ = 1 カード」でグルーピングする際、
111
118
  // 深さではなくディレクトリ形で判定するため、置き場の形をライブラリと合わせておく必要はないが、
112
119
  // カテゴリ別に整理された配置の方が人間が見ても分かりやすいのでライブラリ型に統一する。
113
- const realSourceDir = realpathSync(sourceDir);
120
+ const realSourceDir = await realpath(sourceDir);
114
121
  const dest = path.join(path.resolve(projectDir), 'assets', category, id);
115
122
  await mkdir(path.dirname(dest), { recursive: true });
116
123
  await rm(dest, { recursive: true, force: true });
@@ -119,8 +126,12 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
119
126
  // このマシンの Node(libuv)は clonefileat 相当が ENOSYS を返し、fs.cp の
120
127
  // COPYFILE_FICLONE では節約が効かない(前段 2026-08-09-project-copy-cow-clone で実測確認済み)。
121
128
  // BSD cp -c は clonefile(2) を Node を介さず直接使うため、同じ OS/FS 上で実際にクローンできる。
122
- const clone = spawnSync('/bin/cp', ['-Rc', realSourceDir, dest], { stdio: 'ignore' });
123
- if (!clone.error && clone.status === 0) {
129
+ const cloned = await new Promise(resolveResult => {
130
+ const child = spawn('/bin/cp', ['-Rc', realSourceDir, dest], { stdio: 'ignore' });
131
+ child.on('error', () => resolveResult(false));
132
+ child.on('close', code => resolveResult(code === 0));
133
+ });
134
+ if (cloned) {
124
135
  return dest;
125
136
  }
126
137
  // クロスボリューム等で -c が失敗したケース。部分的に書かれた dest を掃除してから
@@ -136,7 +147,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
136
147
 
137
148
  async function moveIntoLibrary(tempDir, destDir) {
138
149
  await mkdir(path.dirname(destDir), { recursive: true });
139
- if (existsSync(destDir)) {
150
+ if (await stat(destDir).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) {
140
151
  await rm(destDir, { recursive: true, force: true });
141
152
  }
142
153
  try {
@@ -152,6 +163,19 @@ async function moveIntoLibrary(tempDir, destDir) {
152
163
  }
153
164
  }
154
165
 
166
+ async function backfillLegacyMetaTier(metaPath, item) {
167
+ let meta;
168
+ try {
169
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
170
+ } catch {
171
+ return;
172
+ }
173
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.hasOwn(meta, 'tier')) return;
174
+ // R2 に公開済みの旧 meta.json(tier 導入前・price のみ)を、sha256 検証済みのうえでカタログの tier で補う互換経路。R2 の meta.json を tier 付きで上げ直したら外せる。
175
+ meta.tier = assetTier(item);
176
+ await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
177
+ }
178
+
155
179
  /**
156
180
  * @param {string} ref カタログの category/id。bare id は一意な場合のみ互換解決する
157
181
  * @param {{ env?: object, fetchImpl?: Function, project?: string|null, force?: boolean, reference?: boolean }} options
@@ -161,16 +185,16 @@ export async function resolve(
161
185
  { env = process.env, fetchImpl = fetch, project = null, force = false, reference = false, timeouts } = {},
162
186
  ) {
163
187
  const home = resolveAssetLibraryRoots(env).write;
164
- const catalog = await loadCatalogForResolve(ref, { env, fetchImpl, timeouts });
188
+ const catalog = await loadCatalogForResolveAsync(ref, { env, fetchImpl, timeouts });
165
189
  const item = findCatalogItem(catalog, ref);
166
190
 
167
191
  const tier = assetTier(item);
168
192
  const hasFiles = Array.isArray(item.files) && item.files.length > 0;
169
- const destDir = localAssetDir(env, item.category, item.id);
193
+ const destDir = path.join(home, item.category, item.id);
170
194
 
171
195
  // キャッシュヒット → 即返す(未購入だったとしても、一度取得済みなら手元にある実体をそのまま使う。
172
196
  // ゲートは「新規に取得するとき」だけにかける)
173
- const cachedDir = cachedAssetDir(env, item.category, item.id);
197
+ const cachedDir = await cachedAssetDirAsync(env, item.category, item.id);
174
198
  if (!force && cachedDir) {
175
199
  const result = { id: item.id, category: item.category, dir: cachedDir, cached: true };
176
200
  if (project && reference) {
@@ -244,6 +268,7 @@ export async function resolve(
244
268
 
245
269
  // still / scene3d 等、meta.json を実体に持つ素材は validate-asset で契約検証してから登録する
246
270
  if (hasMeta) {
271
+ await backfillLegacyMetaTier(path.join(tempAssetDir, 'meta.json'), item);
247
272
  const result = await validateAsset(tempAssetDir);
248
273
  if (result.status !== 0) {
249
274
  const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
@@ -327,6 +352,8 @@ async function resolvePaidZip(item, { env, fetchImpl, project, reference, home,
327
352
  });
328
353
  }
329
354
 
355
+ await backfillLegacyMetaTier(path.join(tempAssetDir, 'meta.json'), item);
356
+
330
357
  {
331
358
  const result = await validateAsset(tempAssetDir);
332
359
  if (result.status !== 0) {