akari-video 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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) {
@@ -3,19 +3,19 @@ import { lstat, readdir, realpath, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
6
- import { readProjectReferences, resolveLibraryFallback } from './project-references.mjs';
6
+ import { readProjectReferences, resolveLibraryFallbackAsync } from './project-references.mjs';
7
7
 
8
8
  const within = (root, target) => {
9
9
  const rel = path.relative(root, target);
10
10
  return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
11
11
  };
12
12
 
13
- export async function resolveProjectAssetPath(project, declaredPath, env = process.env) {
13
+ export async function resolveProjectAssetPath(project, declaredPath, env = process.env, context) {
14
14
  const normalized = declaredPath.replaceAll('\\', '/');
15
15
  if (path.isAbsolute(normalized) || normalized.split('/').some(part => part === '..' || part === '.')) {
16
16
  throw new Error('素材パスがプロジェクトの外を指しています');
17
17
  }
18
- const root = await realpath(project);
18
+ const root = context?.projectRoot ?? await realpath(project);
19
19
  const local = path.resolve(root, normalized);
20
20
  if (!within(root, local)) throw new Error('素材パスがプロジェクトの外を指しています');
21
21
  // An existing local entry always wins, including a broken or escaping symlink:
@@ -35,34 +35,44 @@ export async function resolveProjectAssetPath(project, declaredPath, env = proce
35
35
  if (!within(root, actual)) throw new Error('素材パスがプロジェクトの外を指しています');
36
36
  return (await stat(actual)).isFile() ? actual : null;
37
37
  }
38
- const references = await readProjectReferences(root);
39
- for (const akariAssetsDir of resolveAssetLibraryRoots(env).read) {
40
- const found = resolveLibraryFallback({ declaredPath: normalized, references, akariAssetsDir });
38
+ const references = context?.references ?? await readProjectReferences(root);
39
+ for (const akariAssetsDir of (context?.libraryRoots ?? resolveAssetLibraryRoots(env).read)) {
40
+ const found = await resolveLibraryFallbackAsync({ declaredPath: normalized, references, akariAssetsDir,
41
+ rootRealpaths: context?.rootRealpaths });
41
42
  if (found) return found;
42
43
  }
43
44
  return null;
44
45
  }
45
46
 
46
47
  /** Enumerate only ledger-authorized files, in library read order; no symlink traversal. */
47
- export async function listProjectReferenceAssets(project, env = process.env) {
48
- const references = await readProjectReferences(project);
48
+ export async function listProjectReferenceAssets(project, env = process.env, context) {
49
+ const references = context?.references ?? await readProjectReferences(project);
50
+ const libraryRoots = context?.libraryRoots ?? resolveAssetLibraryRoots(env).read;
51
+ const rootRealpaths = context?.rootRealpaths ?? new Map();
49
52
  return Promise.all(references.map(async reference => {
50
53
  const files = new Map();
51
54
  let libraryDir;
52
55
  if ([reference.category, reference.id].some(value => !value || value === '.' || value === '..' || /[\\/]/.test(value))) {
53
56
  return { ...reference, files: [] };
54
57
  }
55
- for (const root of resolveAssetLibraryRoots(env).read) {
58
+ for (const root of libraryRoots) {
56
59
  const directory = path.resolve(root, reference.category, reference.id);
57
60
  if (!within(path.resolve(root), directory)) continue;
58
- try { if (!within(await realpath(root), await realpath(directory))) continue; } catch { continue; }
61
+ const lexicalRoot = path.resolve(root);
62
+ let actualRoot = rootRealpaths.get(lexicalRoot);
63
+ if (!actualRoot) {
64
+ actualRoot = realpath(lexicalRoot);
65
+ rootRealpaths.set(lexicalRoot, actualRoot);
66
+ }
67
+ try { if (!within(await actualRoot, await realpath(directory))) continue; } catch { continue; }
59
68
  const walk = async (dir, prefix = '') => {
60
69
  for (const entry of await readdir(dir, { withFileTypes: true })) {
61
70
  const name = prefix + entry.name;
62
71
  if (entry.isDirectory()) await walk(path.join(dir, entry.name), `${name}/`);
63
72
  else {
64
- const absolute = resolveLibraryFallback({
73
+ const absolute = await resolveLibraryFallbackAsync({
65
74
  declaredPath: `assets/${reference.category}/${reference.id}/${name}`, references, akariAssetsDir: root,
75
+ rootRealpaths,
66
76
  });
67
77
  if (absolute && !files.has(name)) {
68
78
  libraryDir ??= directory;
@@ -81,11 +91,14 @@ export async function listProjectReferenceAssets(project, env = process.env) {
81
91
  export async function projectReferenceMediaUris(project, env = process.env, declaredPaths = []) {
82
92
  const uris = {};
83
93
  const paths = new Set(declaredPaths);
84
- for (const asset of await listProjectReferenceAssets(project, env)) {
94
+ const context = { projectRoot: await realpath(project), references: await readProjectReferences(project),
95
+ libraryRoots: resolveAssetLibraryRoots(env).read, rootRealpaths: new Map() };
96
+ for (const asset of await listProjectReferenceAssets(project, env, context)) {
85
97
  for (const file of asset.files) paths.add(`assets/${asset.category}/${asset.id}/${file.name}`);
86
98
  }
87
- for (const declared of paths) {
88
- const actual = await resolveProjectAssetPath(project, declared, env);
99
+ const resolved = await Promise.all([...paths].map(async declared =>
100
+ [declared, await resolveProjectAssetPath(project, declared, env, context)]));
101
+ for (const [declared, actual] of resolved) {
89
102
  if (actual) uris[declared.replaceAll('\\', '/')] = pathToFileURL(actual).href;
90
103
  }
91
104
  return uris;
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import { syncBuiltinESMExports } from 'node:module';
4
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import test from 'node:test';
9
+ import { listProjectReferenceAssets, projectReferenceMediaUris } from '../src/shell-reference.mjs';
10
+
11
+ const names = ['realpathSync', 'lstatSync', 'statSync', 'readFileSync', 'readdirSync', 'existsSync'];
12
+
13
+ async function fixture(t) {
14
+ const root = await mkdtemp(path.join(tmpdir(), 'reference-async-'));
15
+ t.after(() => rm(root, { recursive: true, force: true }));
16
+ const project = path.join(root, 'project');
17
+ const library = path.join(root, 'library');
18
+ await mkdir(path.join(project, '.akari'), { recursive: true });
19
+ await mkdir(path.join(library, 'still', 'sample'), { recursive: true });
20
+ await writeFile(path.join(project, '.akari', 'asset-references.json'),
21
+ JSON.stringify({ version: 0, references: [{ category: 'still', id: 'sample' }] }));
22
+ const env = { ...process.env, AKARI_HOME: path.join(root, 'home'),
23
+ AKARI_CREATOR_ROOT: path.join(root, 'creator'), AKARI_LIBRARY_ROOT: library };
24
+ return { project, library, env };
25
+ }
26
+
27
+ test('参照 URI と列挙は素材数に応じた同期 I/O を行わない', async t => {
28
+ const f = await fixture(t);
29
+ const originals = Object.fromEntries(names.map(name => [name, fs[name]]));
30
+ let direct = 0;
31
+ let total = 0;
32
+ for (const name of names) {
33
+ fs[name] = (...args) => {
34
+ total++;
35
+ const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
36
+ if (/asset-resolver[/\\]src[/\\](shell-reference|project-references)\.mjs/.test(caller)) direct++;
37
+ return originals[name](...args);
38
+ };
39
+ }
40
+ syncBuiltinESMExports();
41
+ t.after(() => {
42
+ for (const name of names) fs[name] = originals[name];
43
+ syncBuiltinESMExports();
44
+ });
45
+ const counts = [];
46
+ for (const size of [3, 30]) {
47
+ const dir = path.join(f.library, 'still', 'sample');
48
+ for (let n = 0; n < size; n++) await writeFile(path.join(dir, `frame-${n}.png`), 'image');
49
+ const before = total;
50
+ const entries = await listProjectReferenceAssets(f.project, f.env);
51
+ const uris = await projectReferenceMediaUris(f.project, f.env);
52
+ counts.push(total - before);
53
+ assert.equal(entries[0].files.length, size);
54
+ assert.deepEqual(new Set(Object.keys(uris)), new Set(Array.from({ length: size }, (_, n) =>
55
+ `assets/still/sample/frame-${n}.png`)));
56
+ assert.equal(uris['assets/still/sample/frame-0.png'],
57
+ pathToFileURL(path.join(dir, 'frame-0.png')).href);
58
+ }
59
+ assert.equal(direct, 0);
60
+ assert.equal(counts[1], counts[0]);
61
+ });
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { resolve as resolveAsset } from '../src/resolve.mjs';
7
+ import { setupFixtureEnv } from './helpers.mjs';
8
+
9
+ function sha256(buffer) {
10
+ return createHash('sha256').update(buffer).digest('hex');
11
+ }
12
+
13
+ function serveMeta({ id = 'mini-still', tier, catalogTier = 'free', omitCatalogTier = false } = {}) {
14
+ const fixture = setupFixtureEnv();
15
+ const item = fixture.catalog.items.find(entry => entry.id === id);
16
+ const file = item.files.find(entry => entry.name === 'meta.json');
17
+ const servedMeta = JSON.parse(readFileSync(path.join(fixture.baseDir, file.key), 'utf8'));
18
+ delete servedMeta.tier;
19
+ if (tier !== undefined) servedMeta.tier = tier;
20
+ const bytes = Buffer.from(`${JSON.stringify(servedMeta, null, 2)}\n`);
21
+ writeFileSync(path.join(fixture.baseDir, file.key), bytes);
22
+ file.sha256 = sha256(bytes);
23
+ file.bytes = bytes.length;
24
+ if (omitCatalogTier) delete item.tier;
25
+ else item.tier = catalogTier;
26
+ writeFileSync(fixture.catalogPath, `${JSON.stringify(fixture.catalog, null, 2)}\n`);
27
+ return { ...fixture, bytes };
28
+ }
29
+
30
+ test('resolve: sha256 検証済みの旧 free meta.json に catalog tier を補完する', async () => {
31
+ const { env, home } = serveMeta({ catalogTier: 'free' });
32
+ const result = await resolveAsset('mini-still', { env });
33
+
34
+ assert.equal(result.cached, false);
35
+ assert.equal(JSON.parse(readFileSync(path.join(result.dir, 'meta.json'), 'utf8')).tier, 'free');
36
+ assert.deepEqual(readdirSync(home).filter(name => name.startsWith('.tmp-resolve-')), []);
37
+ });
38
+
39
+ test('resolve: catalog に tier がなく price: 0 の旧 meta.json は free として解決する', async () => {
40
+ const { env } = serveMeta({ omitCatalogTier: true });
41
+ const result = await resolveAsset('mini-still', { env });
42
+ assert.equal(result.cached, false);
43
+ assert.equal(JSON.parse(readFileSync(path.join(result.dir, 'meta.json'), 'utf8')).tier, 'free');
44
+ });
45
+
46
+ test('resolve: meta.json に tier がある場合は配信バイト列を保持する', async () => {
47
+ const { env, bytes } = serveMeta({ tier: 'free', catalogTier: 'free' });
48
+ const result = await resolveAsset('mini-still', { env });
49
+ assert.equal(result.cached, false);
50
+ assert.equal(readFileSync(path.join(result.dir, 'meta.json'), 'utf8'), bytes.toString('utf8'));
51
+ assert.equal(existsSync(path.join(result.dir, 'meta.json')), true);
52
+ });
@@ -75,15 +75,7 @@ function cap(kind, filled, x, y, direction, size, color, sw, minimumOutline) {
75
75
  const ow = Math.max(minimumOutline, sw * .7);
76
76
  const outline = `fill="${filled ? color : 'none'}" stroke="${color}" stroke-width="${num(ow)}"`;
77
77
  if (kind === 'triangle') {
78
- let tipInset = 0;
79
- if (!filled) {
80
- tipInset = ow * Math.sqrt(1.25);
81
- for (let i = 0; i < 12; i++) {
82
- tipInset = ow * Math.hypot(size - tipInset, h) / (2 * h);
83
- }
84
- }
85
- const tip = x - direction * tipInset;
86
- return `<polygon points="${num(tip)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" ${filled ? `fill="${color}"` : outline}/>`;
78
+ return `<polygon points="${num(x)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" fill="${color}"/>`;
87
79
  }
88
80
  if (kind === 'chevron') {
89
81
  // Account for the miter at the tip and the stroke at the two roots.
@@ -112,6 +104,12 @@ function capInset(kind, size, strokeWidth) {
112
104
  return strokeWidth / 2;
113
105
  if (kind === 'chevron')
114
106
  return chevronGeometry(size, strokeWidth).miter;
107
+ if (kind === 'diamond')
108
+ return size - Math.min(strokeWidth / 2, size / 2);
109
+ if (kind === 'circle') {
110
+ const radius = size / 2;
111
+ return radius + Math.sqrt(Math.max(0, radius * radius - (strokeWidth / 2) ** 2));
112
+ }
115
113
  return size;
116
114
  }
117
115
  function strokeMetrics(visibleWidth, scaleX, scaleY) {
@@ -119,7 +117,7 @@ function strokeMetrics(visibleWidth, scaleX, scaleY) {
119
117
  return {
120
118
  width: visibleWidth / correction,
121
119
  gap: Math.max(visibleWidth * 2, 3) / correction,
122
- capSize: Math.max(visibleWidth * 2.5, 8) / correction,
120
+ capSize: Math.max(visibleWidth * 3.75, 12) / correction,
123
121
  minimumOutline: 1 / correction,
124
122
  };
125
123
  }
@@ -142,10 +140,16 @@ function lineBody(p, width, height, metrics, color, visibleStrokeWidth, scaleX,
142
140
  const rounded = p.lineCap === 'round' && dash !== 'dot';
143
141
  const dashAttr = dashAttribute(dash, metrics, rounded);
144
142
  const stretched = scaleX !== scaleY;
145
- const capSize = stretched ? Math.max(visibleStrokeWidth * 2.5, 8) : size;
146
- const startSize = capSize * clamp(p.startCapScale, 1, .5, 3);
147
- const endSize = capSize * clamp(p.endCapScale, 1, .5, 3);
143
+ const capSize = stretched ? Math.max(visibleStrokeWidth * 3.75, 12) : size;
148
144
  const capStroke = stretched ? visibleStrokeWidth : sw;
145
+ // At the smallest slider value the open arms must remain longer than 3w.
146
+ // A 4w envelope gives a centerline arm of about 3.16w.
147
+ const effectiveSize = (kind, scale) => {
148
+ const scaled = capSize * clamp(scale, 1, .5, 3);
149
+ return kind === 'chevron' ? Math.max(scaled, capStroke * 4) : scaled;
150
+ };
151
+ const startSize = effectiveSize(start, p.startCapScale);
152
+ const endSize = effectiveSize(end, p.endCapScale);
149
153
  const inset = (kind, capLength) => capInset(kind, capLength, capStroke) / (stretched ? scaleX : 1);
150
154
  const extension = rounded ? sw / 2 : 0;
151
155
  const x1 = inset(start, startSize) + extension;
@@ -4035,15 +4035,7 @@ var AkariEditKernel = (() => {
4035
4035
  const ow = Math.max(minimumOutline, sw * 0.7);
4036
4036
  const outline = `fill="${filled ? color2 : "none"}" stroke="${color2}" stroke-width="${num(ow)}"`;
4037
4037
  if (kind === "triangle") {
4038
- let tipInset = 0;
4039
- if (!filled) {
4040
- tipInset = ow * Math.sqrt(1.25);
4041
- for (let i = 0; i < 12; i++) {
4042
- tipInset = ow * Math.hypot(size - tipInset, h) / (2 * h);
4043
- }
4044
- }
4045
- const tip = x - direction * tipInset;
4046
- return `<polygon points="${num(tip)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" ${filled ? `fill="${color2}"` : outline}/>`;
4038
+ return `<polygon points="${num(x)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" fill="${color2}"/>`;
4047
4039
  }
4048
4040
  if (kind === "chevron") {
4049
4041
  const { halfWidth, miter, root } = chevronGeometry(size, sw);
@@ -4067,6 +4059,11 @@ var AkariEditKernel = (() => {
4067
4059
  if (kind === "none") return 0;
4068
4060
  if (kind === "bar") return strokeWidth / 2;
4069
4061
  if (kind === "chevron") return chevronGeometry(size, strokeWidth).miter;
4062
+ if (kind === "diamond") return size - Math.min(strokeWidth / 2, size / 2);
4063
+ if (kind === "circle") {
4064
+ const radius = size / 2;
4065
+ return radius + Math.sqrt(Math.max(0, radius * radius - (strokeWidth / 2) ** 2));
4066
+ }
4070
4067
  return size;
4071
4068
  }
4072
4069
  function strokeMetrics(visibleWidth, scaleX, scaleY) {
@@ -4074,7 +4071,7 @@ var AkariEditKernel = (() => {
4074
4071
  return {
4075
4072
  width: visibleWidth / correction,
4076
4073
  gap: Math.max(visibleWidth * 2, 3) / correction,
4077
- capSize: Math.max(visibleWidth * 2.5, 8) / correction,
4074
+ capSize: Math.max(visibleWidth * 3.75, 12) / correction,
4078
4075
  minimumOutline: 1 / correction
4079
4076
  };
4080
4077
  }
@@ -4096,10 +4093,14 @@ var AkariEditKernel = (() => {
4096
4093
  const rounded = p.lineCap === "round" && dash !== "dot";
4097
4094
  const dashAttr = dashAttribute(dash, metrics, rounded);
4098
4095
  const stretched = scaleX !== scaleY;
4099
- const capSize = stretched ? Math.max(visibleStrokeWidth * 2.5, 8) : size;
4100
- const startSize = capSize * clamp3(p.startCapScale, 1, 0.5, 3);
4101
- const endSize = capSize * clamp3(p.endCapScale, 1, 0.5, 3);
4096
+ const capSize = stretched ? Math.max(visibleStrokeWidth * 3.75, 12) : size;
4102
4097
  const capStroke = stretched ? visibleStrokeWidth : sw;
4098
+ const effectiveSize = (kind, scale) => {
4099
+ const scaled = capSize * clamp3(scale, 1, 0.5, 3);
4100
+ return kind === "chevron" ? Math.max(scaled, capStroke * 4) : scaled;
4101
+ };
4102
+ const startSize = effectiveSize(start, p.startCapScale);
4103
+ const endSize = effectiveSize(end, p.endCapScale);
4103
4104
  const inset = (kind, capLength) => capInset(kind, capLength, capStroke) / (stretched ? scaleX : 1);
4104
4105
  const extension = rounded ? sw / 2 : 0;
4105
4106
  const x1 = inset(start, startSize) + extension;