akari-video 1.1.0 → 1.1.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 (36) hide show
  1. package/package.json +1 -1
  2. package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +9 -4
  3. package/vendor/docs/contract-2026-10-04-asset-tier-v1.md +8 -0
  4. package/vendor/packages/akari-launcher/package.json +1 -1
  5. package/vendor/packages/asset-resolver/bin/akari-assets.mjs +10 -7
  6. package/vendor/packages/asset-resolver/src/bundle.mjs +1 -1
  7. package/vendor/packages/asset-resolver/src/env.mjs +15 -0
  8. package/vendor/packages/asset-resolver/src/fetch-file.mjs +42 -5
  9. package/vendor/packages/asset-resolver/src/list-filter.mjs +10 -0
  10. package/vendor/packages/asset-resolver/src/pro-asset.mjs +103 -0
  11. package/vendor/packages/asset-resolver/src/resolve.mjs +107 -31
  12. package/vendor/packages/asset-resolver/test/installed-assets.test.mjs +2 -2
  13. package/vendor/packages/asset-resolver/test/list-filter.test.mjs +54 -0
  14. package/vendor/packages/asset-resolver/test/resolve-paid-zip.test.mjs +9 -0
  15. package/vendor/packages/asset-resolver/test/resolve-pro-asset.test.mjs +445 -0
  16. package/vendor/packages/edit-lint/src/lint/captions.mjs +3 -2
  17. package/vendor/packages/edit-store/lib/caption-clock.d.ts +3 -0
  18. package/vendor/packages/edit-store/lib/caption-clock.js +57 -6
  19. package/vendor/packages/edit-store/lib/caption-display.d.ts +9 -0
  20. package/vendor/packages/edit-store/lib/caption-display.js +177 -27
  21. package/vendor/packages/edit-store/lib/caption-store.d.ts +1 -1
  22. package/vendor/packages/edit-store/lib/caption-store.js +1 -1
  23. package/vendor/packages/edit-store/lib/caption-timeline.d.ts +31 -0
  24. package/vendor/packages/edit-store/lib/caption-timeline.js +67 -0
  25. package/vendor/packages/edit-store/lib/cut-ranges.d.ts +8 -0
  26. package/vendor/packages/edit-store/lib/cut-ranges.js +188 -1
  27. package/vendor/packages/edit-store/lib/internal-model.d.ts +2 -0
  28. package/vendor/packages/edit-store/lib/internal-model.js +2 -0
  29. package/vendor/packages/edit-store/lib/webview-kernel.js +4897 -4746
  30. package/vendor/packages/schemas/bin/validate-captions.mjs +7 -4
  31. package/vendor/packages/schemas/captions.schema.json +2 -1
  32. package/vendor/packages/schemas/test/validate-captions.test.mjs +15 -1
  33. package/vendor/skills/design-world/SKILL.md +1 -1
  34. package/vendor/skills/edit-plan/expression-selection.md +4 -2
  35. package/vendor/skills/setup-audio-library/first-party.md +1 -1
  36. package/vendor/skills/setup-library/SKILL.md +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.1.0",
3
+ "version": "1.1.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": {
@@ -42,10 +42,15 @@ overlap; and reference/output aspect mismatch. A resolved production render stor
42
42
  projection digest, and its immutable render receipt references the file and summary.
43
43
  Captions with `time_domain: "output"` (placed text) are exempt from overlap and order checks; any number may share the same time (2026-09-22 decision). Source-domain captions retain their per-source checks.
44
44
 
45
- Under `display_policy`, an omitted `captions[].style` remains valid. The known word-display styles
46
- `karaoke`, `pop`, `reveal`, and `reveal-word` remain `STYLE_CONFLICT` errors, while any other value is
47
- an `INVALID_CAPTION` error that names the unknown value and the accepted vocabulary. Without
48
- `display_policy`, caption style handling remains on the unchanged legacy path.
45
+ 2026-10-06 amendment: `display_policy.word_style` is the global word display default (`none` when
46
+ omitted, or `karaoke`). An explicit `captions[].style: "karaoke"` or `"plain"` takes priority over
47
+ that default; `plain` restores ordinary display for one row under a global karaoke policy.
48
+ Under `display_policy`, `pop`, `reveal`, and `reveal-word` remain `STYLE_CONFLICT` errors, while any
49
+ unknown style is `INVALID_CAPTION`. Karaoke uses measured `words[]` projected into each resolved
50
+ fragment. Missing words or synthetic timing fall back to ordinary text. Without `display_policy`,
51
+ caption style handling remains on the unchanged legacy path.
52
+ If measured words omit only punctuation, symbols, or whitespace, those characters remain visible as
53
+ untimed unlit tokens while the measured words keep karaoke timing; other text mismatches fall back to ordinary text.
49
54
 
50
55
  `reference-pixel` geometry is scaled only after exact aspect agreement. `webkit-outline` produces
51
56
  real `-webkit-text-stroke` plus `paint-order:stroke fill` and disables shadow. Single-line consumers
@@ -42,3 +42,11 @@ JSON Schema は `remote: true` なら `source` 必須までを表し、akari-r2
42
42
  `preview` URL は `https://akari.video/lab/media/telop-rich-pack/textstyle/<id>.png` の形とする。
43
43
  配置後は既存の textstyle ライブラリ読み込みが `preset.json` を読み、
44
44
  「テキスト > スタイル」へ並べる。未取得 textstyle の棚カード表示は別票で扱う。
45
+
46
+ ## § Pro item の 1 件ずつの取得(2026-10-06)
47
+
48
+ resolver は取得済みの素材をキャッシュから先に返す。新規取得の Pro item は店の entitlements と catalog 行で資格を事前判定し、資格があれば `GET /api/store/v1/assets/<category>/<id>` で記述子を取得する。記述子が示す各ファイルを Bearer 認証で取得し、`sha256` と `bytes` を照合する。`meta.json` を必須として `validate-asset` に通し、全件成功後に `<ライブラリ>/<category>/<id>/` へ原子的に配置する。失敗時は一時ディレクトリを破棄する。
49
+
50
+ 記述子の `schema` は `akari-pro-asset/v1`。`category`、`id`、正の整数 `version` と、空でない `files` 配列を持つ。各 `files[]` は相対パス `name`、小文字 64 桁の `sha256`、0 以上の整数 `bytes`、記述子 API と同じオリジンの絶対 HTTP(S) `url` を持つ。`name` は `/` 区切りで、空の区間、`.`、`..`、先頭の `/`、バックスラッシュ、NUL を含めない。`name` は一意で `meta.json` を必ず含む。
51
+
52
+ 記述子またはファイル取得で 409 `stale_version` が返ったら、一時取得物を捨てて記述子を 1 回だけ取り直す。記述子 API が 404 `asset_not_found` を返した場合だけ、`product_id ?? id` の束 zip 取得へ進む。束 zip は予備経路として段階的に退役する。記述子 API の上書きは `AKARI_PRO_ASSET_API` を使い、`AKARI_STORE_API`、接続情報の URL、既定ホストより優先する。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.1.0",
3
+ "version": "1.1.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": [
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // akari-assets — 素材 resolver v0 の CLI(list / add / fetch / bundle / migrate / sync / browse)。
3
3
  //
4
- // akari-assets list [--category <c>] [--source <lab|site|own>] [--json]
4
+ // akari-assets list [--category <c>] [--source <lab|site|own>] [--tag <t> ...] [--query <s>] [--json]
5
5
  // akari-assets add <path...> --plan [--json]
6
6
  // akari-assets add --apply <plan.json> [--json]
7
7
  // akari-assets fetch <ref> [--project <dir>] [--reference] [--force] (ref は category/id)
@@ -19,6 +19,7 @@ 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
21
  import { checkLibrary, projectCredits } from '../src/library-check.mjs';
22
+ import { filterListItems } from '../src/list-filter.mjs';
22
23
 
23
24
  function flagValue(args, name) {
24
25
  const i = args.indexOf(name);
@@ -28,7 +29,7 @@ function flagValue(args, name) {
28
29
  // Validate the entire command before any handler can read or mutate user data.
29
30
  function validateArgs(sub, args) {
30
31
  const specs = {
31
- list: { values: ['--category', '--source'], flags: ['--json'], max: 0 },
32
+ list: { values: ['--category', '--source', '--tag', '--query'], flags: ['--json'], max: 0 },
32
33
  add: { values: ['--apply'], flags: ['--plan', '--json'], max: Infinity },
33
34
  fetch: { values: ['--project'], flags: ['--reference', '--force'], min: 1, max: 1 },
34
35
  bundle: { values: ['--project'], flags: ['--dry-run'], max: 0 },
@@ -46,7 +47,7 @@ function validateArgs(sub, args) {
46
47
  const arg = args[i];
47
48
  if (!arg.startsWith('-')) { positional.push(arg); continue; }
48
49
  if (!spec.values.includes(arg) && !spec.flags.includes(arg)) throw new Error(`不明なオプション: ${arg}`);
49
- if (seen.has(arg)) throw new Error(`重複したオプション: ${arg}`);
50
+ if (seen.has(arg) && !(sub === 'list' && arg === '--tag')) throw new Error(`重複したオプション: ${arg}`);
50
51
  seen.add(arg);
51
52
  if (spec.values.includes(arg) && (!args[++i] || args[i].startsWith('-'))) throw new Error(`${arg} には値が必要です`);
52
53
  }
@@ -76,10 +77,12 @@ async function cmdList(args, env) {
76
77
  const category = flagValue(args, '--category');
77
78
  const asJson = args.includes('--json');
78
79
  const source = flagValue(args, '--source');
80
+ const tags = args.flatMap((arg, index) => arg === '--tag' ? [args[index + 1]] : []);
81
+ const query = flagValue(args, '--query');
79
82
  if (args.includes('--source') && !['lab', 'site', 'own'].includes(source)) throw new Error('--source は lab / site / own で指定してください');
80
83
  const { libraryRoots, items, warnings } = await composeState({ env });
81
84
  for (const warning of warnings) console.error(`警告: ${warning}`);
82
- const filtered = items.filter(item => (!category || item.category === category) && (!source || item.sourceKind === source));
85
+ const filtered = filterListItems(items, { category, source, tags, query });
83
86
 
84
87
  if (asJson) {
85
88
  console.log(JSON.stringify(filtered, null, 2));
@@ -88,7 +91,7 @@ async function cmdList(args, env) {
88
91
 
89
92
  console.log(`使える素材 ${filtered.length} 件(ライブラリ: ${libraryRoots.write})`);
90
93
  for (const item of filtered) {
91
- console.log(` ${badgeOf(item)} ${item.category}/${item.id}\t${item.sourceKind}\t[${item.category}]\t${item.title}`);
94
+ console.log(` ${badgeOf(item)} ${item.category}/${item.id}\t${item.sourceKind}\t[${item.category}]\t${item.tier === 'pro' ? 'pro' : 'free'}\t${item.title}`);
92
95
  }
93
96
  }
94
97
 
@@ -199,8 +202,8 @@ async function cmdCredits(args, env) {
199
202
  function printUsage() {
200
203
  console.log(`使い方: akari-assets <list|add|fetch|bundle|migrate|sync|browse|check|credits> [options]
201
204
 
202
- list [--category <c>] [--source <lab|site|own>] [--json]
203
- 出どころ・取得状態つき素材一覧
205
+ list [--category <c>] [--source <lab|site|own>] [--tag <t> ...] [--query <s>] [--json]
206
+ タグ完全一致(複数は AND)・題名/id/タグ部分一致で絞る素材一覧
204
207
  add <path...> --plan [--json] ローカル素材の取り込み計画(書き込みなし)
205
208
  add --apply <plan.json> [--json] 計画で選択した素材を複製して登録
206
209
  fetch <ref> [--project <dir>] [--reference] [--force]
@@ -14,7 +14,7 @@ export async function bundleProjectReferences({
14
14
  for (const reference of planned) {
15
15
  try {
16
16
  if (!isAssetCached(env, reference.category, reference.id)) {
17
- const resolved = await resolveAsset(reference.id, { env });
17
+ const resolved = await resolveAsset(`${reference.category}/${reference.id}`, { env });
18
18
  if (resolved.category !== reference.category) {
19
19
  throw new Error(
20
20
  `カタログのカテゴリが台帳と一致しません: ${reference.category}/${reference.id}(実際: ${resolved.category})`,
@@ -90,3 +90,18 @@ export function resolveDownloadUrl(env = process.env, credentials, productId) {
90
90
  }
91
91
  return `${DEFAULT_STORE_API}/api/store/v1/download/${encodeURIComponent(productId)}`;
92
92
  }
93
+
94
+ /** Pro item の記述子 API。専用上書き → 店 API → 接続情報 → 既定ホスト。 */
95
+ export function resolveProAssetUrl(env = process.env, credentials, category, id) {
96
+ const suffix = `${encodeURIComponent(category)}/${encodeURIComponent(id)}`;
97
+ if (env.AKARI_PRO_ASSET_API) {
98
+ return `${trimTrailingSlash(env.AKARI_PRO_ASSET_API)}/api/store/v1/assets/${suffix}`;
99
+ }
100
+ if (env.AKARI_STORE_API) {
101
+ return `${trimTrailingSlash(env.AKARI_STORE_API)}/api/store/v1/assets/${suffix}`;
102
+ }
103
+ if (credentials?.url) {
104
+ return `${trimTrailingSlash(credentials.url)}/v1/assets/${suffix}`;
105
+ }
106
+ return `${DEFAULT_STORE_API}/api/store/v1/assets/${suffix}`;
107
+ }
@@ -6,11 +6,12 @@
6
6
  // (開発時は store リポのローカル出力を指す運用)。
7
7
 
8
8
  import { createWriteStream } from 'node:fs';
9
- import { copyFile, mkdir } from 'node:fs/promises';
9
+ import { copyFile, mkdir, rm } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { Readable } from 'node:stream';
12
12
  import { pipeline } from 'node:stream/promises';
13
13
  import { isRemoteLocation } from './env.mjs';
14
+ import { AssetResolverError } from './errors.mjs';
14
15
 
15
16
  export const DEFAULT_FETCH_TIMEOUTS = { responseMs: 15_000, idleMs: 30_000 };
16
17
 
@@ -98,14 +99,50 @@ export function resolvePreviewLocation(base, preview) {
98
99
  }
99
100
 
100
101
  /** 解決済みロケーションを destPath へ実体化する(リモートは fetch、ローカルはファイルコピー) */
101
- export async function materialize({ location, remote }, destPath, { fetchImpl = fetch, timeouts } = {}) {
102
+ export async function materialize({ location, remote }, destPath, { fetchImpl = fetch, timeouts, request, maxBytes } = {}) {
102
103
  await mkdir(path.dirname(destPath), { recursive: true });
103
104
  if (remote) {
104
- const { response: res, controller } = await fetchTimed(location, { fetchImpl, timeouts, label: '素材ファイル' });
105
+ const { response: res, controller } = await fetchTimed(location, { fetchImpl, request, timeouts, label: '素材ファイル' });
105
106
  if (!res.ok || !res.body) {
106
- throw new Error(`ダウンロード失敗: ${location} → HTTP ${res.status}`);
107
+ if (!request) throw new Error(`ダウンロード失敗: ${location} → HTTP ${res.status}`);
108
+ let detail = '';
109
+ let storeCode;
110
+ try {
111
+ const data = await readTimedJson(res, controller, { timeouts, label: '素材ファイル' });
112
+ storeCode = typeof data?.error === 'string' ? data.error : undefined;
113
+ detail = [storeCode, typeof data?.message === 'string' ? data.message : ''].filter(Boolean).join(': ');
114
+ } catch (error) {
115
+ if (error instanceof Error && error.message.includes('時間切れ')) throw error;
116
+ // エラー本文が JSON でない場合も HTTP status を残す。
117
+ }
118
+ controller.abort();
119
+ const error = new Error(`ダウンロード失敗: ${location} → HTTP ${res.status}${detail ? ` (${detail})` : ''}`);
120
+ error.status = res.status;
121
+ error.storeCode = storeCode;
122
+ throw error;
123
+ }
124
+ if (maxBytes === undefined) {
125
+ await pipeline(Readable.from(timedBody(res.body, controller, { timeouts, label: '素材ファイル' })), createWriteStream(destPath));
126
+ return;
127
+ }
128
+ async function* limitedBody() {
129
+ let received = 0;
130
+ for await (const chunk of timedBody(res.body, controller, { timeouts, label: '素材ファイル' })) {
131
+ received += chunk.byteLength;
132
+ if (received > maxBytes) {
133
+ controller.abort();
134
+ throw new AssetResolverError(`bytes が宣言値を超えました: ${received} > ${maxBytes}`, 'integrity');
135
+ }
136
+ yield chunk;
137
+ }
138
+ }
139
+ try {
140
+ await pipeline(Readable.from(limitedBody()), createWriteStream(destPath));
141
+ } catch (error) {
142
+ controller.abort();
143
+ await rm(destPath, { force: true });
144
+ throw error;
107
145
  }
108
- await pipeline(Readable.from(timedBody(res.body, controller, { timeouts, label: '素材ファイル' })), createWriteStream(destPath));
109
146
  return;
110
147
  }
111
148
  await copyFile(location, destPath);
@@ -0,0 +1,10 @@
1
+ /** Filter composed catalogue rows without changing their order or contents. */
2
+ export function filterListItems(items, { category, source, tags = [], query } = {}) {
3
+ const needle = query?.toLocaleLowerCase();
4
+ return items.filter(item =>
5
+ (!category || item.category === category)
6
+ && (!source || item.sourceKind === source)
7
+ && tags.every(tag => item.tags?.includes(tag))
8
+ && (!needle || [item.title, item.id, ...(item.tags ?? [])]
9
+ .some(value => typeof value === 'string' && value.toLocaleLowerCase().includes(needle))));
10
+ }
@@ -0,0 +1,103 @@
1
+ // Pro item の記述子と店の HTTP 応答を検査する。ファイル取得前に全件を検査する。
2
+ import { AssetResolverError } from './errors.mjs';
3
+ import { resolveProAssetUrl } from './env.mjs';
4
+ import { fetchTimed, readTimedJson } from './fetch-file.mjs';
5
+
6
+ function timedOut(error) {
7
+ return error instanceof Error && error.message.includes('時間切れ');
8
+ }
9
+
10
+ export function storeFailure(status, data, context) {
11
+ const storeCode = typeof data?.error === 'string' ? data.error : undefined;
12
+ const message = typeof data?.message === 'string' ? data.message : undefined;
13
+ const details = [storeCode, message].filter(Boolean).join(': ');
14
+ const error = new AssetResolverError(
15
+ `${context}に失敗しました(HTTP ${status ?? '不明'}${details ? `: ${details}` : ''})`,
16
+ status === 401 || (status === 403 && storeCode === 'pro_required') ? 'locked' : 'download_failed',
17
+ );
18
+ error.status = status;
19
+ error.storeCode = storeCode;
20
+ return error;
21
+ }
22
+
23
+ function invalid(reason) {
24
+ throw new AssetResolverError(`Pro 素材の記述子が不正です: ${reason}`, 'integrity');
25
+ }
26
+
27
+ export function validateProAssetDescriptor(data, item, descriptorUrl) {
28
+ if (!data || typeof data !== 'object' || Array.isArray(data)) invalid('JSON object が必要です');
29
+ if (data.schema !== 'akari-pro-asset/v1') invalid('schema');
30
+ if (data.category !== item.category || data.id !== item.id) invalid('category/id');
31
+ if (!Number.isInteger(data.version) || data.version <= 0) invalid('version');
32
+ if (!Array.isArray(data.files) || data.files.length === 0) invalid('files');
33
+ const origin = new URL(descriptorUrl).origin;
34
+ const names = new Set();
35
+ const files = [];
36
+ for (const file of data.files) {
37
+ if (!file || typeof file !== 'object' || Array.isArray(file)) invalid('file');
38
+ if (Object.keys(file).length !== 4
39
+ || Object.keys(file).some(key => !['name', 'sha256', 'bytes', 'url'].includes(key))) invalid('file のキー');
40
+ const name = file.name;
41
+ if (typeof name !== 'string' || !name || name.startsWith('/') || name.includes('\\')
42
+ || name.split('/').some(part => !part || part === '.' || part === '..'
43
+ || /[:\x00-\x1f\x7f<>"|?*]/.test(part) || /[. ]$/.test(part)
44
+ || /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part))) invalid('name');
45
+ const foldedName = name.toLowerCase();
46
+ if (names.has(foldedName)) invalid('name が重複しています');
47
+ names.add(foldedName);
48
+ if (typeof file.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(file.sha256)) invalid('sha256');
49
+ if (!Number.isInteger(file.bytes) || file.bytes < 0) invalid('bytes');
50
+ let url;
51
+ try { url = new URL(file.url); } catch { invalid('url'); }
52
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin) invalid('url のオリジン');
53
+ files.push({ name, sha256: file.sha256, bytes: file.bytes, url: file.url });
54
+ }
55
+ if (!files.some(file => file.name === 'meta.json')) invalid('meta.json');
56
+ return { ...data, files };
57
+ }
58
+
59
+ /** 404 asset_not_found だけを null として返し、zip 予備経路へ渡す。 */
60
+ export async function fetchProAssetDescriptor(item, credentials, { env, fetchImpl, timeouts }) {
61
+ const url = resolveProAssetUrl(env, credentials, item.category, item.id);
62
+ let res;
63
+ let controller;
64
+ try {
65
+ ({ response: res, controller } = await fetchTimed(url, {
66
+ fetchImpl, request: { headers: { authorization: `Bearer ${credentials.token}` }, redirect: 'error' },
67
+ timeouts, label: 'Pro 素材の記述子',
68
+ }));
69
+ } catch (error) {
70
+ throw new AssetResolverError(`Pro 素材の記述子取得に失敗しました: ${error.message}`, timedOut(error) ? 'timeout' : 'download_failed');
71
+ }
72
+ let data;
73
+ try {
74
+ data = await readTimedJson(res, controller, { timeouts, label: 'Pro 素材の記述子' });
75
+ } catch (error) {
76
+ if (timedOut(error)) throw new AssetResolverError(error.message, 'timeout');
77
+ if (res.ok) invalid('JSON');
78
+ const failure = storeFailure(res.status, null, 'Pro 素材の記述子取得');
79
+ if (res.status === 401) failure.message += '。トークン失効の可能性があります。akari store connect をやり直してください';
80
+ throw failure;
81
+ } finally {
82
+ controller.abort();
83
+ }
84
+ if (!res.ok) {
85
+ if (res.status === 404 && data?.error === 'asset_not_found') return null;
86
+ if (res.status === 403 && data?.error === 'pro_required') {
87
+ const error = new AssetResolverError(
88
+ `Pro 素材は all-access-pass(Lifetime パス)または購入済み product_id が必要です: ${item.id}${data?.message ? `(${data.message})` : ''}`,
89
+ 'locked',
90
+ );
91
+ error.status = res.status;
92
+ error.storeCode = data.error;
93
+ throw error;
94
+ }
95
+ if (res.status === 401) {
96
+ const failure = storeFailure(res.status, data, 'Pro 素材の記述子取得');
97
+ failure.message += '。トークン失効の可能性があります。akari store connect をやり直してください';
98
+ throw failure;
99
+ }
100
+ throw storeFailure(res.status, data, 'Pro 素材の記述子取得');
101
+ }
102
+ return validateProAssetDescriptor(data, item, url);
103
+ }
@@ -7,9 +7,8 @@ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
7
7
  // 失敗は fail-closed(一時ディレクトリを破棄し、登録先には部分状態を残さない)。
8
8
  // 有料未購入(locked)は resolve を拒否する。
9
9
  //
10
- // Pro item は公開 catalog に files[] を持たない(実体は非公開 R2 のまま)。entitled
11
- // なら resolvePaidZip() が `/api/store/v1/download/<id>` から zip を取得し、展開 →
12
- // checksums.txt 検証(paid-zip.mjs)→ 同じ validate-asset / 原子的 move の経路に合流する。
10
+ // Pro item は公開 catalog に files[] を持たない。記述子 API から 1 件ずつ取得する。
11
+ // 記述子が asset_not_found のときだけ、従来の束 zip を予備経路として使う。
13
12
 
14
13
  import { spawn } from 'node:child_process';
15
14
  import { constants } from 'node:fs';
@@ -25,6 +24,7 @@ import { materialize, resolveFileLocation } from './fetch-file.mjs';
25
24
  import { sha256File } from './hash.mjs';
26
25
  import { downloadPaidZip, extractZip, verifyPaidZipContents } from './paid-zip.mjs';
27
26
  import { recordProjectReference } from './project-references.mjs';
27
+ import { fetchProAssetDescriptor, storeFailure } from './pro-asset.mjs';
28
28
  import { assetTier, isAssetEntitled } from './tier.mjs';
29
29
 
30
30
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -155,7 +155,12 @@ async function moveIntoLibrary(tempDir, destDir) {
155
155
  } catch (error) {
156
156
  // 一時ディレクトリと登録先が別ファイルシステムの場合(EXDEV)は copy + rm でフォールバック
157
157
  if (error && error.code === 'EXDEV') {
158
- await cp(tempDir, destDir, { recursive: true, mode: constants.COPYFILE_FICLONE });
158
+ try {
159
+ await cp(tempDir, destDir, { recursive: true, mode: constants.COPYFILE_FICLONE });
160
+ } catch (copyError) {
161
+ await rm(destDir, { recursive: true, force: true });
162
+ throw copyError;
163
+ }
159
164
  await rm(tempDir, { recursive: true, force: true });
160
165
  return;
161
166
  }
@@ -176,6 +181,98 @@ async function backfillLegacyMetaTier(metaPath, item) {
176
181
  await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
177
182
  }
178
183
 
184
+ async function fetchFilesInto(files, tempAssetDir, item, { base, fetchImpl, timeouts, request, checkBytes = false, proUrlsOnly = false }) {
185
+ for (const file of files) {
186
+ if (typeof file.name !== 'string' || !file.name) {
187
+ throw new AssetResolverError(`files[] エントリに name がありません: ${item.id}`, 'invalid_catalog_item');
188
+ }
189
+ }
190
+ for (let start = 0; start < files.length; start += 4) {
191
+ const batch = files.slice(start, start + 4);
192
+ const results = await Promise.allSettled(batch.map(async file => {
193
+ const destPath = path.join(tempAssetDir, file.name);
194
+ const resolved = proUrlsOnly ? { location: file.url, remote: true } : resolveFileLocation(base, file);
195
+ await materialize(resolved, destPath, { fetchImpl, timeouts, request, ...(proUrlsOnly ? { maxBytes: file.bytes } : {}) });
196
+ if (file.sha256) {
197
+ const actual = await sha256File(destPath);
198
+ if (actual !== file.sha256) {
199
+ throw new AssetResolverError(
200
+ `sha256 が一致しません(改竄または破損の可能性): ${item.id}/${file.name}(期待 ${file.sha256} / 実際 ${actual})`,
201
+ 'integrity',
202
+ );
203
+ }
204
+ }
205
+ if (checkBytes) {
206
+ const actual = (await stat(destPath)).size;
207
+ if (actual !== file.bytes) {
208
+ throw new AssetResolverError(`bytes が一致しません: ${item.id}/${file.name}(期待 ${file.bytes} / 実際 ${actual})`, 'integrity');
209
+ }
210
+ }
211
+ }));
212
+ const stale = checkBytes && results.find(result => result.status === 'rejected'
213
+ && result.reason?.status === 409 && result.reason?.storeCode === 'stale_version');
214
+ if (stale) throw stale.reason;
215
+ const failed = results.find(result => result.status === 'rejected');
216
+ if (failed) throw failed.reason;
217
+ }
218
+ }
219
+
220
+ async function resolveProAsset(item, credentials, options) {
221
+ const { env, fetchImpl, project, reference, home, destDir, timeouts } = options;
222
+ for (let attempt = 0; attempt < 2; attempt++) {
223
+ let tempRoot;
224
+ try {
225
+ const descriptor = await fetchProAssetDescriptor(item, credentials, { env, fetchImpl, timeouts });
226
+ if (descriptor === null) return resolvePaidZip(item, options);
227
+ await mkdir(home, { recursive: true });
228
+ tempRoot = await mkdtemp(path.join(home, '.tmp-resolve-'));
229
+ const tempAssetDir = path.join(tempRoot, item.category, item.id);
230
+ await mkdir(tempAssetDir, { recursive: true });
231
+ try {
232
+ await fetchFilesInto(descriptor.files, tempAssetDir, item, {
233
+ base: null, fetchImpl, timeouts, checkBytes: true, proUrlsOnly: true,
234
+ request: { headers: { authorization: `Bearer ${credentials.token}` }, redirect: 'error' },
235
+ });
236
+ } catch (error) {
237
+ if (error.status === 401) {
238
+ const failure = storeFailure(error.status, { error: error.storeCode }, 'Pro 素材のファイル取得');
239
+ failure.message += `。${error.message}。トークン失効の可能性があります。akari store connect をやり直してください`;
240
+ throw failure;
241
+ }
242
+ if (error.status === 403 && error.storeCode === 'pro_required') {
243
+ throw new AssetResolverError(
244
+ `Pro 素材は all-access-pass(Lifetime パス)または購入済み product_id が必要です: ${item.id}(${error.message})`,
245
+ 'locked',
246
+ );
247
+ }
248
+ if (error.status) throw storeFailure(error.status, { error: error.storeCode, message: error.message }, 'Pro 素材のファイル取得');
249
+ if (error instanceof AssetResolverError) throw error;
250
+ throw new AssetResolverError(`Pro 素材のファイル取得に失敗しました: ${error.message}`, error.message?.includes('時間切れ') ? 'timeout' : 'download_failed');
251
+ }
252
+ await backfillLegacyMetaTier(path.join(tempAssetDir, 'meta.json'), item);
253
+ const validation = await validateAsset(tempAssetDir);
254
+ if (validation.status !== 0) {
255
+ const output = `${validation.stdout ?? ''}${validation.stderr ?? ''}`.trim();
256
+ throw new AssetResolverError(`validate-asset 検証に失敗しました: ${item.id}\n${output}`, 'validation');
257
+ }
258
+ await moveIntoLibrary(tempAssetDir, destDir);
259
+ const result = { id: item.id, category: item.category, dir: destDir, cached: false };
260
+ if (project && reference) {
261
+ await recordProjectReference(project, item);
262
+ result.referenced = true;
263
+ } else if (project) {
264
+ result.projectDir = await copyIntoProject(destDir, project, item.category, item.id);
265
+ }
266
+ return result;
267
+ } catch (error) {
268
+ if (error.status === 409 && error.storeCode === 'stale_version' && attempt === 0) continue;
269
+ throw error;
270
+ } finally {
271
+ if (tempRoot) await rm(tempRoot, { recursive: true, force: true }).catch(() => {});
272
+ }
273
+ }
274
+ }
275
+
179
276
  /**
180
277
  * @param {string} ref カタログの category/id。bare id は一意な場合のみ互換解決する
181
278
  * @param {{ env?: object, fetchImpl?: Function, project?: string|null, force?: boolean, reference?: boolean }} options
@@ -219,10 +316,12 @@ export async function resolve(
219
316
  'locked',
220
317
  );
221
318
  }
222
- // 有料カタログ item は files[] を持たない設計(実体は非公開 R2 のまま。tools/publish-free.mjs
223
- // 側の掲載規律の裏返し)。entitled 済みなら zip ダウンロード経路(契約 §6/§8)で取得する。
224
319
  if (!hasFiles) {
225
- return resolvePaidZip(item, { env, fetchImpl, project, reference, home, destDir, timeouts });
320
+ const credentials = await readStoreCredentials(env);
321
+ if (!credentials) {
322
+ throw new AssetResolverError(`AKARI アカウントの接続情報がありません(トークン失効の可能性): ${item.id}`, 'locked');
323
+ }
324
+ return resolveProAsset(item, credentials, { env, fetchImpl, project, reference, home, destDir, timeouts });
226
325
  }
227
326
  }
228
327
 
@@ -241,30 +340,7 @@ export async function resolve(
241
340
  try {
242
341
  await mkdir(tempAssetDir, { recursive: true });
243
342
  const hasMeta = item.files.some(file => file.name === 'meta.json');
244
- for (const file of item.files) {
245
- if (typeof file.name !== 'string' || !file.name) {
246
- throw new AssetResolverError(`files[] エントリに name がありません: ${item.id}`, 'invalid_catalog_item');
247
- }
248
- }
249
- for (let start = 0; start < item.files.length; start += 4) {
250
- const batch = item.files.slice(start, start + 4);
251
- const results = await Promise.allSettled(batch.map(async file => {
252
- const destPath = path.join(tempAssetDir, file.name);
253
- const resolved = resolveFileLocation(base, file);
254
- await materialize(resolved, destPath, { fetchImpl, timeouts });
255
- if (file.sha256) {
256
- const actual = await sha256File(destPath);
257
- if (actual !== file.sha256) {
258
- throw new AssetResolverError(
259
- `sha256 が一致しません(改竄または破損の可能性): ${item.id}/${file.name}(期待 ${file.sha256} / 実際 ${actual})`,
260
- 'integrity',
261
- );
262
- }
263
- }
264
- }));
265
- const failed = results.find(result => result.status === 'rejected');
266
- if (failed) throw failed.reason;
267
- }
343
+ await fetchFilesInto(item.files, tempAssetDir, item, { base, fetchImpl, timeouts });
268
344
 
269
345
  // still / scene3d 等、meta.json を実体に持つ素材は validate-asset で契約検証してから登録する
270
346
  if (hasMeta) {
@@ -179,8 +179,8 @@ for (const indexState of ['missing', 'empty']) {
179
179
  assert.equal(list.status, 0, list.stderr);
180
180
  assert.equal(list.stdout,
181
181
  `使える素材 2 件(ライブラリ: ${path.join(home, 'assets')})\n`
182
- + ' ☁ still/mini-still\tlab\t[still]\tフィクスチャ素材 mini-still\n'
183
- + ' 🔒 Pro still/mini-paid\tlab\t[still]\tフィクスチャ素材 mini-paid(有料)\n');
182
+ + ' ☁ still/mini-still\tlab\t[still]\tfree\tフィクスチャ素材 mini-still\n'
183
+ + ' 🔒 Pro still/mini-paid\tlab\t[still]\tpro\tフィクスチャ素材 mini-paid(有料)\n');
184
184
 
185
185
  const fetchResult = runCli(['fetch', 'mini-still'], env);
186
186
  assert.equal(fetchResult.status, 0, fetchResult.stderr);
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { filterListItems } from '../src/list-filter.mjs';
6
+
7
+ const rows = [
8
+ { category: 'overlay', id: 'blue-card', title: 'Blue Card', tags: ['sticker', 'blue'], sourceKind: 'lab' },
9
+ { category: 'overlay', id: 'red-card', title: '赤いカード', tags: ['sticker', 'red'], sourceKind: 'lab' },
10
+ { category: 'audio', id: 'tap', title: 'Tap', tags: ['blue', 'sound'], sourceKind: 'site' },
11
+ ];
12
+
13
+ test('list filters tags with exact AND matching', () => {
14
+ assert.deepEqual(filterListItems(rows, { tags: ['sticker'] }).map(item => item.id), ['blue-card', 'red-card']);
15
+ assert.deepEqual(filterListItems(rows, { tags: ['sticker', 'blue'] }).map(item => item.id), ['blue-card']);
16
+ assert.deepEqual(filterListItems(rows, { tags: ['stick'] }), []);
17
+ });
18
+
19
+ test('list query matches title, id, and tags without case', () => {
20
+ for (const query of ['BLUE CARD', 'BLUE-CARD', 'STICKER']) {
21
+ assert.deepEqual(filterListItems(rows, { query, category: 'overlay', source: 'lab' }).map(item => item.id),
22
+ query === 'STICKER' ? ['blue-card', 'red-card'] : ['blue-card']);
23
+ }
24
+ assert.deepEqual(filterListItems(rows, { query: 'missing' }), []);
25
+ });
26
+
27
+ const cli = fileURLToPath(new URL('../bin/akari-assets.mjs', import.meta.url));
28
+ const cliSource = readFileSync(cli, 'utf8');
29
+ const validateArgs = new Function(`${cliSource.slice(cliSource.indexOf('function validateArgs('), cliSource.indexOf('const STATE_BADGE'))}\nreturn validateArgs;`)();
30
+ test('list CLI accepts repeated tags and rejects repeated or missing query values', () => {
31
+ assert.doesNotThrow(() => validateArgs('list', ['--tag', 'sticker', '--tag', 'blue', '--query', 'CARD', '--json']));
32
+ for (const args of [['--query', 'a', '--query', 'b'], ['--query'], ['--tag'], ['--tag', 'a', '--tag']]) {
33
+ assert.throws(() => validateArgs('list', args), /重複したオプション|値が必要/);
34
+ }
35
+ });
36
+
37
+ test('list human output keeps badge and origin columns and adds tier before title', async () => {
38
+ const printed = [];
39
+ const source = cliSource.slice(cliSource.indexOf('function flagValue('), cliSource.indexOf('// Validate the entire command'))
40
+ + cliSource.slice(cliSource.indexOf('const STATE_BADGE'), cliSource.indexOf('async function cmdAdd('));
41
+ const cmdList = new Function('filterListItems', 'composeState', 'console', `${source}\nreturn cmdList;`)(
42
+ filterListItems,
43
+ async () => ({ libraryRoots: { write: 'library' }, warnings: [], items: [
44
+ { category: 'overlay', id: 'free-card', title: 'Free Card', sourceKind: 'lab', tier: 'free', state: 'available' },
45
+ { category: 'overlay', id: 'pro-card', title: 'Pro Card', sourceKind: 'lab', tier: 'pro', state: 'locked' },
46
+ ] }),
47
+ { log: line => printed.push(line), error: line => printed.push(line) });
48
+ await cmdList([], {});
49
+ assert.deepEqual(printed, [
50
+ '使える素材 2 件(ライブラリ: library)',
51
+ ' ☁ overlay/free-card\tlab\t[overlay]\tfree\tFree Card',
52
+ ' 🔒 Pro overlay/pro-card\tlab\t[overlay]\tpro\tPro Card',
53
+ ]);
54
+ });
@@ -125,6 +125,9 @@ function fetchImplFor(id, { entitled, zipPath }) {
125
125
  assert.equal(options.headers?.authorization, 'Bearer akst_test');
126
126
  return { ok: true, json: async () => ({ entitlements: entitled ? [{ product_id: 'all-access-pass' }] : [] }) };
127
127
  }
128
+ if (/\/v1\/assets\/[^/]+\/[^/]+$/.test(s)) {
129
+ return { ok: false, status: 404, json: async () => ({ error: 'asset_not_found', message: '記述子はありません' }) };
130
+ }
128
131
  if (s.endsWith(`/v1/download/${id}`)) {
129
132
  assert.equal(options.headers?.authorization, 'Bearer akst_test');
130
133
  if (!zipPath) {
@@ -327,6 +330,9 @@ test('textstyle Pro 2 件: 未契約は locked、パスで available、束 zip
327
330
  if (String(url).endsWith('/v1/entitlements')) {
328
331
  return { ok: true, status: 200, json: async () => ({ pass: pass ? { tier: 1, seat_no: 1 } : null, entitlements: [] }) };
329
332
  }
333
+ if (/\/v1\/assets\/[^/]+\/[^/]+$/.test(String(url))) {
334
+ return { ok: false, status: 404, json: async () => ({ error: 'asset_not_found', message: '記述子はありません' }) };
335
+ }
330
336
  assert.ok(pass, '未契約では zip を取得しない');
331
337
  assert.ok(String(url).endsWith(`/v1/download/${productId}`));
332
338
  return { ok: true, status: 200, body: Readable.toWeb(createReadStream(zipPath)) };
@@ -379,6 +385,9 @@ test('Lifetime パスの展開済み entitlements でパック全件が availabl
379
385
  if (String(url).endsWith('/v1/entitlements')) {
380
386
  return { ok: true, status: 200, json: async () => ({ entitlements }) };
381
387
  }
388
+ if (/\/v1\/assets\/[^/]+\/[^/]+$/.test(String(url))) {
389
+ return { ok: false, status: 404, json: async () => ({ error: 'asset_not_found', message: '記述子はありません' }) };
390
+ }
382
391
  downloads.push(String(url));
383
392
  assert.ok(String(url).endsWith(`/v1/download/${productId}`));
384
393
  return { ok: true, status: 200, body: Readable.toWeb(createReadStream(zipPath)) };