akari-video 0.1.70 → 0.1.71

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 (28) hide show
  1. package/bin/akari.mjs +1 -1
  2. package/package.json +1 -1
  3. package/src/kits.mjs +95 -3
  4. package/src/store-command.mjs +2 -0
  5. package/vendor/.akari-capability-sources.json +3 -0
  6. package/vendor/docs/contract-2026-09-13-extension-kit-v0.md +2 -1
  7. package/vendor/docs/contract-2026-09-13-world-map-v0.md +24 -4
  8. package/vendor/packages/akari-launcher/package.json +1 -1
  9. package/vendor/packages/asset-resolver/src/library.mjs +13 -6
  10. package/vendor/packages/asset-resolver/src/resolve.mjs +4 -3
  11. package/vendor/packages/asset-resolver/test/resolve-kit-symlink.test.mjs +69 -0
  12. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/LICENSE.md +3 -0
  13. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/README.md +3 -0
  14. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/assets/overlay/sample-kit-frame/fragment.html +10 -0
  15. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/assets/overlay/sample-kit-frame/meta.json +24 -0
  16. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/assets/overlay/sample-kit-frame/preview.png +0 -0
  17. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/manifest.json +25 -0
  18. package/vendor/packages/schemas/examples/kit-manifest-v1-with-asset/skills/sample-kit-skill/SKILL.md +8 -0
  19. package/vendor/skills/design-world/SKILL.md +94 -0
  20. package/vendor/skills/design-world/bin/expand-template.mjs +198 -0
  21. package/vendor/skills/design-world/bin/test/expand-template.test.mjs +152 -0
  22. package/vendor/skills/design-world/bin/test/package.json +4 -0
  23. package/vendor/skills/design-world/guide.md +17 -0
  24. package/vendor/skills/design-world/templates/browser-to-chat.json +39 -0
  25. package/vendor/skills/design-world/templates/paper-to-browser.json +38 -0
  26. package/vendor/skills/design-world/templates/street-to-room.json +39 -0
  27. package/vendor/skills/design-world/world.md +62 -0
  28. package/vendor/skills/overlay-authoring/world.md +39 -0
package/bin/akari.mjs CHANGED
@@ -38,7 +38,7 @@ async function printVersion() {
38
38
  // `--help` は claude/opencode へそのまま転送されてしまっていた — AKARI Video 自身の
39
39
  // コマンド一覧が一度も出ない行き止まりだったため新設した)。
40
40
  async function printCliHelp() {
41
- for (const line of [...describeCliHelp(), ' world ワールド地図を検査・生成・プレビュー']) {
41
+ for (const line of [...describeCliHelp(), ' world ワールド地図を検査・生成・プレビュー・停留所移動']) {
42
42
  console.log(line);
43
43
  }
44
44
  return { exitCode: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
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": {
package/src/kits.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import {
3
4
  existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync,
4
- renameSync, rmSync, symlinkSync, writeFileSync
5
+ readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync
5
6
  } from 'node:fs';
6
7
  import path from 'node:path';
7
8
 
8
9
  import { resolveLauncherAssets } from './repo-assets.mjs';
9
10
 
10
11
  const KITS_SCHEMA = 'akari-installed-kits/v0';
12
+ const INSTALLED_ASSETS_SCHEMA = 'akari-installed-assets/v0';
11
13
  const PLUGIN_DESCRIPTION = 'AKARI Video 拡張キットのスキルをまとめて提供するローカルプラグイン。';
12
14
 
13
15
  function parseVersion(value) {
@@ -93,9 +95,47 @@ function replaceSymlink(source, destination, {
93
95
  }
94
96
  }
95
97
 
98
+ function listAssetFiles(assetRoot, current = assetRoot) {
99
+ const files = [];
100
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
101
+ const filePath = path.join(current, entry.name);
102
+ const stat = statSync(filePath);
103
+ if (stat.isDirectory()) {
104
+ files.push(...listAssetFiles(assetRoot, filePath));
105
+ } else if (stat.isFile()) {
106
+ const content = readFileSync(filePath);
107
+ files.push({
108
+ path: path.relative(assetRoot, filePath).split(path.sep).join('/'),
109
+ bytes: stat.size,
110
+ sha256: createHash('sha256').update(content).digest('hex')
111
+ });
112
+ }
113
+ }
114
+ return files.sort((left, right) => left.path.localeCompare(right.path));
115
+ }
116
+
117
+ function installedAssetItem(source, asset, manifest) {
118
+ const assetRoot = realpathSync(source);
119
+ let title = asset.id;
120
+ try {
121
+ const meta = JSON.parse(readFileSync(path.join(assetRoot, 'meta.json'), 'utf8'));
122
+ if (typeof meta.title === 'string' && meta.title) title = meta.title;
123
+ } catch {
124
+ // validate-asset が検査済み。配布環境で検査器が無い場合だけ id へフォールバックする。
125
+ }
126
+ return {
127
+ id: asset.id,
128
+ title,
129
+ path: ['assets', asset.category, asset.id].join('/'),
130
+ version: manifest.version,
131
+ files: listAssetFiles(assetRoot)
132
+ };
133
+ }
134
+
96
135
  export function linkKitAssets(kitDir, manifest, home, options = {}) {
97
136
  const warnings = [];
98
137
  const linked = [];
138
+ const items = [];
99
139
  const assets = options.assets?.schemasSourceDir !== undefined
100
140
  ? options.assets
101
141
  : resolveLauncherAssets(options.assets);
@@ -113,9 +153,14 @@ export function linkKitAssets(kitDir, manifest, home, options = {}) {
113
153
  warnings.push(`素材 ${asset.category}/${asset.id} の検査ツールが見つからないため検査をスキップしました。`);
114
154
  }
115
155
  const destination = path.join(home, 'assets', asset.category, asset.id);
156
+ const productRoot = path.join(home, 'assets', 'store', manifest.id);
157
+ const kitSubdir = path.relative(productRoot, kitDir);
116
158
  const result = replaceSymlink(source, destination, {
117
159
  ...options,
118
- relativeTarget: path.join('..', '..', 'assets', 'store', manifest.id, 'assets', asset.category, asset.id)
160
+ relativeTarget: path.join(
161
+ '..', '..', 'assets', 'store', manifest.id, kitSubdir,
162
+ 'assets', asset.category, asset.id
163
+ )
119
164
  });
120
165
  if (result.status === 'occupied') {
121
166
  warnings.push(`既存の実ディレクトリを保持しました: ${destination}`);
@@ -123,9 +168,55 @@ export function linkKitAssets(kitDir, manifest, home, options = {}) {
123
168
  warnings.push(`symlink を作成できませんでした(Windows の権限を確認してください): ${destination}`);
124
169
  } else {
125
170
  linked.push({ category: asset.category, id: asset.id });
171
+ items.push(installedAssetItem(source, asset, manifest));
126
172
  }
127
173
  }
128
- return { linked, warnings };
174
+ return { linked, items, warnings };
175
+ }
176
+
177
+ function readInstalledAssetsIndex(home) {
178
+ const indexPath = path.join(home, 'assets', 'installed.json');
179
+ if (!existsSync(indexPath)) return { schema: INSTALLED_ASSETS_SCHEMA, packs: {} };
180
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
181
+ if (index?.schema !== INSTALLED_ASSETS_SCHEMA
182
+ || !index.packs || typeof index.packs !== 'object' || Array.isArray(index.packs)) {
183
+ throw new Error(`導入済み素材索引の形式が想定と違います: ${indexPath}`);
184
+ }
185
+ return index;
186
+ }
187
+
188
+ function writeInstalledAssetsIndex(home, index) {
189
+ const indexPath = path.join(home, 'assets', 'installed.json');
190
+ mkdirSync(path.dirname(indexPath), { recursive: true });
191
+ const temporary = `${indexPath}.tmp-${process.pid}`;
192
+ writeFileSync(temporary, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
193
+ renameSync(temporary, indexPath);
194
+ }
195
+
196
+ export function registerKitAssets(home, manifest, kitDir, items) {
197
+ const root = path.resolve(kitDir);
198
+ const storeRoot = path.join(path.resolve(home), 'assets', 'store', manifest.id);
199
+ if (root !== storeRoot && !root.startsWith(`${storeRoot}${path.sep}`)) {
200
+ throw new Error(`キット素材の root が展開先の外を指しています: ${root}`);
201
+ }
202
+ const index = readInstalledAssetsIndex(home);
203
+ index.packs[manifest.id] = {
204
+ version: manifest.version,
205
+ installedAt: new Date().toISOString(),
206
+ root,
207
+ items
208
+ };
209
+ writeInstalledAssetsIndex(home, index);
210
+ return items;
211
+ }
212
+
213
+ function unregisterKitAssets(home, productId) {
214
+ const indexPath = path.join(home, 'assets', 'installed.json');
215
+ if (!existsSync(indexPath)) return;
216
+ const index = readInstalledAssetsIndex(home);
217
+ if (!Object.hasOwn(index.packs, productId)) return;
218
+ delete index.packs[productId];
219
+ writeInstalledAssetsIndex(home, index);
129
220
  }
130
221
 
131
222
  export function linkKitSkills(kitDir, manifest, home, options = {}) {
@@ -198,6 +289,7 @@ export function removeKit(home, productId) {
198
289
  const temporary = `${ledgerPath}.tmp-${process.pid}`;
199
290
  writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
200
291
  renameSync(temporary, ledgerPath);
292
+ unregisterKitAssets(home, productId);
201
293
  return true;
202
294
  }
203
295
 
@@ -28,6 +28,7 @@ import {
28
28
  linkKitSkills,
29
29
  readKitManifest,
30
30
  readKitsLedger,
31
+ registerKitAssets,
31
32
  removeKit,
32
33
  writeKitsLedger
33
34
  } from './kits.mjs';
@@ -540,6 +541,7 @@ export async function runStoreCommand(args, options = {}) {
540
541
  for (const blocker of skillLinks.blockers) log(`導入できません: ${blocker}`);
541
542
  return { exitCode: 1 };
542
543
  }
544
+ registerKitAssets(home, manifest, kitDir, assetLinks.items);
543
545
  writeKitsLedger(home, {
544
546
  id: manifest.id,
545
547
  version: manifest.version,
@@ -152,6 +152,9 @@
152
152
  "skills/declare-audio/launch.md",
153
153
  "skills/declare-audio/SKILL.md",
154
154
  "skills/declare-audio/what-to-declare.md",
155
+ "skills/design-world/guide.md",
156
+ "skills/design-world/SKILL.md",
157
+ "skills/design-world/world.md",
155
158
  "skills/edit-lint/preview.md",
156
159
  "skills/edit-lint/SKILL.md",
157
160
  "skills/edit-plan/approvals-and-generation.md",
@@ -51,6 +51,7 @@
51
51
 
52
52
  1. manifest と `requires` を検査する。CLI または runtime の不足は fail-closed、依存商品の不足は警告と導入案内にする。
53
53
  2. `assets[]` を `~/.akari/assets/<category>/<id>` へ相対 symlink で公開する。各素材はリンク前に `validate-asset.mjs` で検査する。
54
+ 素材の実体ファイルと checksum は `~/.akari/assets/installed.json` にも登録し、素材 id から解決できるようにする。
54
55
  3. `skills[]` を `~/.akari/kits/plugin/skills/<name>` へ相対 symlink で公開する。
55
56
  4. `~/.akari/kits/installed.json` に id、version、導入日時、展開先、スキル、素材を記録する。
56
57
  5. `templates[]` は移動せず、CLI が各展開先の manifest を列挙して読む。
@@ -72,7 +73,7 @@ Codex、Cursor、opencode では、プロジェクトの `.agents/.codex/.cursor
72
73
 
73
74
  ## 5. アプリ(ホームの拡張キットカード)
74
75
 
75
- ホームの AKARI Store カードの隣に拡張キットカードを 1 枚出し、未接続では出さず、導入済みは id・version・スキル名・素材数の一覧と未有効化時の有効化案内、購入済み・未導入は `akari store install <id>` の案内、未購入は教材の引換ページの案内、という 3 状態とする(アプリはコマンドを実行せず、コピーと外部ブラウザ起動だけを行う)。
76
+ ホームの AKARI Store カードの隣に拡張キットカードを 1 枚出し、未接続では出さず、導入済みは id・version・スキル名・素材数の一覧と未有効化時の有効化案内、購入済み・未導入は `akari store install <id>` の案内、未購入は Lab の商品ページの案内(Lifetime パス対象)、という 3 状態とする(アプリはコマンドを実行せず、コピーと外部ブラウザ起動だけを行う)。
76
77
 
77
78
  ## 6. 更新と版
78
79
 
@@ -26,27 +26,47 @@
26
26
 
27
27
  flat world は 1 個の overlay 断片で構成する。Canvas 層は背景、格子、遠景、portal、cut の覆いを描き、DOM sheet 層は素材と文字を持つ。各 world は直下の `.akari-world-sheet[data-world]`、zone はその子の `.akari-world-zone[data-zone]` とし、sheet 自身は left / top 0、zone の px は bounds 原点を引かない world 座標そのままとする。sheet の transform は authoring 時に固定せず、ランタイムが `camera(t)` から設定する。DOM と Canvas の混在出力は rasterize 経路を使う。
28
28
 
29
- spatial world は three 断片で構成し、座標・床・背景・霧を宣言する。画面座標の 3D 小物は別 overlay item とする。
29
+ spatial world は `akari world build` `assets/world/world.glb` `overlays/world.html` の
30
+ three 断片へ決定論的に焼く。GLB は `worlds[].spatial.floor`、`background`、`haze`、
31
+ `zones[].c` の目印と、`camera(t)` を 60 Hz でサンプルした `TourCamera` / `Tour` clip を持つ。
32
+ three 宣言は `model`、`camera.fromModel: "TourCamera"`、`animationClip: "Tour"` に加え、
33
+ 先頭 world の `palette.haze` / `palette.background` がある場合だけ `fog` / `background` を持つ。
34
+ 画面座標の 3D 小物とテロップは別 overlay item とする。
30
35
 
31
36
  ## 4. CLI
32
37
 
33
38
  - `akari world check [--strict] [--migrate] [--json]`: スキーマと不変条件を検査し、必要なら v2 を v3 へ正規化する。
34
- - `akari world build`: flat world の宣言、sheet、zone、解決済み素材断片を `overlays/world.html` に生成し、edit.json の `world` item id 安定で upsert する。
35
- - `akari world preview [--measure]`: stop edge の代表時点を PNG`camera-proof.json` にする。measure 時は非 move edge の完全被覆区間を 30 Hz で測り、該当する `transition.cover` だけを書き戻す。
39
+ - `check --migrate` はラベル文字列または `null` の `cover` v3 語彙外の `pattern` を落として有限の暫定値へ正規化し、元の値と実測が必要な旨を注記に残す。
40
+ - `akari world build`: flat は宣言、sheet、zone、解決済み素材断片を `overlays/world.html` に生成する。spatial は世界 GLB three 断片を生成する。どちらも edit.json`world` item id 安定で upsert する。edit.json version 2 でなければ変更せず停止するため、先に `akari migrate <project-root>` を実行する。
41
+ - `akari world preview [--measure]`: flat / spatial とも rasterize 経路で stop と edge の代表時点を PNG と `camera-proof.json` にする。measure 時は非 move edge の全画素 RGB 標準偏差が 2 以下になる完全被覆区間を 30 Hz で測り、該当する `transition.cover` だけを書き戻す。
42
+ - `preview --measure` は入口では C7 を問わず、実測値を書き戻した後に C7 を含む全項目を検査する。
36
43
  - `akari world overview`: 外部通信を行わず `file://` で開ける自己完結の俯瞰 HTML を生成する。
44
+ - `akari world move-stop <project-root> --stop <id> --c x,y[,scale] [--json]`: flat の停留所座標だけを更新する。元テキストの整形と他の欄を変えず、bounds 外・spatial・不変条件違反では一切書き込まない。
37
45
 
38
46
  同じ入力から得る HTML と画像は決定論的でなければならない。素材 id は asset resolver で解決し、未解決時は失敗として扱う。
39
47
 
48
+ 実行順は flat / spatial 共通で、プロジェクトルートに対して次のようにする。
49
+
50
+ ```sh
51
+ akari world check . --migrate
52
+ akari world build .
53
+ akari world preview .
54
+ akari world preview . --measure
55
+ akari world overview .
56
+ ```
57
+
40
58
  ## 5. 地図 UI
41
59
 
42
60
  - 実装のマーカー判定は `akari-shell-strip` の ContextKey `akari.worldMap` に一元化する。
43
61
  - main の「地図」タブは `akari-world-view` が担う。
44
62
  - タイムラインのワールド帯と地図インスペクターは `akari-annotations` が担う。
45
63
 
46
- 地図 UI は world-map を読み取り専用で表示する。2D 俯瞰、ワールド帯、再生時刻に追従する撮影枠、選択中の stop / edge 詳細を提供し、データの編集機能は持たない。
64
+ 地図 UI は 2D 俯瞰、ワールド帯、再生時刻に追従する撮影枠、選択中の stop / edge 詳細を提供する。v1 では flat の停留所の座標だけを ⌥ ドラッグで `world-map.json` へ書き戻せる。書き手は `akari world move-stop` の 1 本に限定し、bounds 外・spatial・不変条件違反では書き込まない。`world-map.json` は edit.json の履歴の外にあるため、undo / redo は未対応とする。
47
65
 
48
66
  ## 6. 制作フロー
49
67
 
68
+ 作り方(ブリーフ → テンプレート → 台本 → `world-map.json` → `akari world`)は無料の純正スキル `akari:design-world`(`skills/design-world/SKILL.md`)が持つ。
69
+
50
70
  企画と絵コンテで章を world として宣言し、モーション区間は `world-map.json` → `akari world build` → overlay → 書き出しの順に処理する。実写区間との接点は portal とカットアウェイ章に限定する。
51
71
 
52
72
  ## 7. 将来拡張
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
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": [
@@ -24,14 +24,21 @@ export function scanLocalLibrary(home) {
24
24
  if (!existsSync(assetsDir)) return installed;
25
25
 
26
26
  for (const categoryEntry of readdirSync(assetsDir, { withFileTypes: true })) {
27
- if (!categoryEntry.isDirectory()) continue;
28
27
  const categoryDir = path.join(assetsDir, categoryEntry.name);
29
- for (const idEntry of readdirSync(categoryDir, { withFileTypes: true })) {
30
- if (!idEntry.isDirectory()) continue;
31
- const dir = path.join(categoryDir, idEntry.name);
32
- if (readdirSync(dir).length > 0) {
33
- installed.add(`${categoryEntry.name}/${idEntry.name}`);
28
+ try {
29
+ if (!statSync(categoryDir).isDirectory()) continue;
30
+ for (const idEntry of readdirSync(categoryDir, { withFileTypes: true })) {
31
+ const dir = path.join(categoryDir, idEntry.name);
32
+ try {
33
+ if (statSync(dir).isDirectory() && readdirSync(dir).length > 0) {
34
+ installed.add(`${categoryEntry.name}/${idEntry.name}`);
35
+ }
36
+ } catch {
37
+ // 壊れた symlink や読めない素材は取得済みとして数えない。
38
+ }
34
39
  }
40
+ } catch {
41
+ // ファイルや壊れた category symlink は対象外。
35
42
  }
36
43
  }
37
44
  return installed;
@@ -11,7 +11,7 @@
11
11
  // checksums.txt 検証(paid-zip.mjs)→ 同じ validate-asset / 原子的 move の経路に合流する。
12
12
 
13
13
  import { spawnSync } from 'node:child_process';
14
- import { constants, existsSync } from 'node:fs';
14
+ import { constants, existsSync, realpathSync } from 'node:fs';
15
15
  import { cp, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
@@ -38,6 +38,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
38
38
  // 素材箱側が「meta.json を含むディレクトリ = 1 カード」でグルーピングする際、
39
39
  // 深さではなくディレクトリ形で判定するため、置き場の形をライブラリと合わせておく必要はないが、
40
40
  // カテゴリ別に整理された配置の方が人間が見ても分かりやすいのでライブラリ型に統一する。
41
+ const realSourceDir = realpathSync(sourceDir);
41
42
  const dest = path.join(path.resolve(projectDir), 'assets', category, id);
42
43
  await mkdir(path.dirname(dest), { recursive: true });
43
44
  await rm(dest, { recursive: true, force: true });
@@ -46,7 +47,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
46
47
  // このマシンの Node(libuv)は clonefileat 相当が ENOSYS を返し、fs.cp の
47
48
  // COPYFILE_FICLONE では節約が効かない(前段 2026-08-09-project-copy-cow-clone で実測確認済み)。
48
49
  // BSD cp -c は clonefile(2) を Node を介さず直接使うため、同じ OS/FS 上で実際にクローンできる。
49
- const clone = spawnSync('/bin/cp', ['-Rc', sourceDir, dest], { stdio: 'ignore' });
50
+ const clone = spawnSync('/bin/cp', ['-Rc', realSourceDir, dest], { stdio: 'ignore' });
50
51
  if (!clone.error && clone.status === 0) {
51
52
  return dest;
52
53
  }
@@ -57,7 +58,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
57
58
 
58
59
  // COPYFILE_FICLONE(_FORCE ではない): 対応 FS(APFS 等)では CoW クローンで実体化コピーを
59
60
  // 省略し、非対応環境では黙って通常コピーへフォールバックする(失敗しない)。
60
- await cp(sourceDir, dest, { recursive: true, mode: constants.COPYFILE_FICLONE });
61
+ await cp(realSourceDir, dest, { recursive: true, mode: constants.COPYFILE_FICLONE });
61
62
  return dest;
62
63
  }
63
64
 
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync
5
+ } from 'node:fs';
6
+ import path from 'node:path';
7
+ import test from 'node:test';
8
+ import { isAssetCached, scanLocalLibrary } from '../src/library.mjs';
9
+ import { resolve as resolveAsset } from '../src/resolve.mjs';
10
+ import { setupFixtureEnv } from './helpers.mjs';
11
+
12
+ function sha256(value) {
13
+ return createHash('sha256').update(value).digest('hex');
14
+ }
15
+
16
+ test('キット素材の symlink 越し fetch はプロジェクトへ実体をコピーする', async () => {
17
+ const { env, home, root } = setupFixtureEnv();
18
+ try {
19
+ const id = 'sample-kit-frame';
20
+ const category = 'overlay';
21
+ const packRoot = path.join(home, 'assets', 'store', 'sample-kit');
22
+ const assetRoot = path.join(packRoot, 'assets', category, id);
23
+ const fragment = '<div>sample kit frame</div>\n';
24
+ mkdirSync(assetRoot, { recursive: true });
25
+ writeFileSync(path.join(assetRoot, 'fragment.html'), fragment);
26
+ mkdirSync(path.join(home, 'assets', category), { recursive: true });
27
+ symlinkSync(path.relative(path.join(home, 'assets', category), assetRoot), path.join(home, 'assets', category, id), 'dir');
28
+ writeFileSync(path.join(home, 'assets', 'installed.json'), `${JSON.stringify({
29
+ schema: 'akari-installed-assets/v0',
30
+ packs: {
31
+ 'sample-kit': {
32
+ version: 1,
33
+ installedAt: '2026-09-14T00:00:00.000Z',
34
+ root: packRoot,
35
+ items: [{
36
+ id,
37
+ title: 'Sample Kit Frame',
38
+ path: `assets/${category}/${id}`,
39
+ version: 1,
40
+ files: [{ path: 'fragment.html', bytes: Buffer.byteLength(fragment), sha256: sha256(fragment) }]
41
+ }]
42
+ }
43
+ }
44
+ }, null, 2)}\n`);
45
+
46
+ assert.equal(isAssetCached(home, category, id), true);
47
+ assert.equal(scanLocalLibrary(home).has(`${category}/${id}`), true);
48
+ const project = path.join(root, 'project');
49
+ const result = await resolveAsset(id, { env, project });
50
+ assert.equal(result.cached, true);
51
+ assert.equal(lstatSync(result.projectDir).isSymbolicLink(), false);
52
+ assert.equal(readFileSync(path.join(result.projectDir, 'fragment.html'), 'utf8'), fragment);
53
+ } finally {
54
+ rmSync(root, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test('壊れた素材 symlink は cached 扱いにもローカル一覧にも入れない', () => {
59
+ const { home, root } = setupFixtureEnv();
60
+ try {
61
+ const categoryDir = path.join(home, 'assets', 'overlay');
62
+ mkdirSync(categoryDir, { recursive: true });
63
+ symlinkSync('../../store/missing/assets/overlay/broken-frame', path.join(categoryDir, 'broken-frame'), 'dir');
64
+ assert.equal(isAssetCached(home, 'overlay', 'broken-frame'), false);
65
+ assert.equal(scanLocalLibrary(home).has('overlay/broken-frame'), false);
66
+ } finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
@@ -0,0 +1,3 @@
1
+ # Fixture license
2
+
3
+ This fictional fixture represents `LicenseRef-AKARI-Assets-v0` metadata.
@@ -0,0 +1,3 @@
1
+ # Fictional sample kit with an asset
2
+
3
+ This directory is a validation fixture and is not a distributed product.
@@ -0,0 +1,10 @@
1
+ <div class="sample-kit-frame" aria-hidden="true"></div>
2
+
3
+ <style>
4
+ .sample-kit-frame {
5
+ position: absolute;
6
+ inset: 8%;
7
+ border: 12px solid #ffb000;
8
+ border-radius: 24px;
9
+ }
10
+ </style>
@@ -0,0 +1,24 @@
1
+ {
2
+ "id": "sample-kit-frame",
3
+ "category": "overlay",
4
+ "title": "架空キットのフレーム",
5
+ "description": "キット同梱素材の登録と解決を検証する最小フレーム。",
6
+ "when_to_use": "拡張キットの素材導入テスト",
7
+ "tags": ["fixture", "frame"],
8
+ "knobs": [],
9
+ "ai_usage": "検証 fixture のため制作物には使用しない。",
10
+ "requires": [],
11
+ "provenance": {
12
+ "origin": "AKARI Video test fixture",
13
+ "generator": "hand-authored"
14
+ },
15
+ "author": "AKARI Labs",
16
+ "license": {
17
+ "spdx": "LicenseRef-AKARI-Assets-v0",
18
+ "scope": "test-only",
19
+ "attribution_required": false,
20
+ "ai_training_allowed": false
21
+ },
22
+ "price": null,
23
+ "version": 1
24
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "sample-kit",
4
+ "kind": "kit",
5
+ "name": "素材入り架空キット",
6
+ "version": 1,
7
+ "requires": {
8
+ "cli": ">=0.1.70",
9
+ "runtimes": ["three"],
10
+ "products": []
11
+ },
12
+ "skills": [
13
+ { "dir": "skills/sample-kit-skill", "name": "sample-kit-skill" }
14
+ ],
15
+ "templates": [],
16
+ "assets": [
17
+ { "category": "overlay", "id": "sample-kit-frame" }
18
+ ],
19
+ "docs": [],
20
+ "license": "LicenseRef-AKARI-Assets-v0",
21
+ "provenance": {
22
+ "author": "AKARI Labs",
23
+ "source": "example:sample-kit-with-asset"
24
+ }
25
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: sample-kit-skill
3
+ description: Fictional skill used only by the kit manifest fixture.
4
+ ---
5
+
6
+ # Sample kit skill
7
+
8
+ Fixture content only.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: design-world
3
+ description: 「ワールドを作って」「地図で見せる動画」「紙からブラウザの中へ入っていく映像」など、複数の世界と停留所を連続カメラで結ぶ flat ワールドを設計・検査・組み立てるときに使う。
4
+ ---
5
+
6
+ # Design World
7
+
8
+ > **Language**: Respond in the user's language — 対話・質問・承認確認・レポートはユーザーの使用言語に合わせる(例: 英語で話しかけられたら英語で応答する)。
9
+
10
+ ## ハードルール
11
+
12
+ - ワールド操作は `akari world check|build|preview|overview` のサブコマンドだけで行う。リポジトリ内の実装ファイルを直接呼ばない。
13
+ - 正本は `planning/world-map.json`。生成物だけを既存の visual track に置き、`edit.json` の語彙を増やさない。
14
+ - `planning/world-map.json` は `expand-template.mjs` に作らせ、手でキーを足さない。world runtime は許可キー以外を拒否する。
15
+ - v0 は `kind: flat` だけを対象にする。`spatial` は設計も build もしない。
16
+ - 座標は台本とテンプレートの layout から決める。既存作品の座標を写さない。
17
+ - 同じ world 内は `move`。世界をまたぐ既定は `portal`。`cut` を選ぶときは `transition.kind: mist` とする。
18
+ - 世界をまたぐすべての辺に空でない `carry` を置き、その全要素を `retainedNodes` に含める。
19
+ - 各 world には停留所を 2 件以上置く。zones と cameraStops の id は一致させる。
20
+ - `edit.json` には**ベース映像**が要る(`sources` 1 件 + visual トラックにベースの item 1 件)。無いと `render-cut` が落ちる。ワールドの overlay だけでは書き出せない。
21
+
22
+ ## 最初に聞く 3 問
23
+
24
+ 1. 世界はいくつで、紙・ブラウザ・街・部屋など何の世界ですか。
25
+ 2. 各世界で見せたい停留所は何ですか。
26
+ 3. 世界をまたいで持ち越す物は何ですか。
27
+
28
+ ## 手順
29
+
30
+ 0. プロジェクトと 15 秒の無地ベースを用意する。手持ち映像を使う場合も `sources` と visual item の両方へ登録する。
31
+
32
+ ```sh
33
+ PROJECT=<project>
34
+ SKILL_DIR=<このスキルのディレクトリの絶対パス>
35
+ mkdir -p "$PROJECT/planning" "$PROJECT/sources"
36
+ ffmpeg -y -f lavfi -i "color=c=#101418:s=1920x1080:r=30:d=15" \
37
+ -pix_fmt yuv420p "$PROJECT/sources/base.mp4"
38
+ ```
39
+
40
+ `$PROJECT/edit.json` は次の v2 骨格にする。15 秒 × 30 fps なので `duration` は 450 フレーム。`akari world build` は id `world` の visual item を追加または更新する。ベース item と時間が重なるため、既存とは別の visual トラックに置かれる。
41
+
42
+ ```json
43
+ {
44
+ "version": 2,
45
+ "output": { "width": 1920, "height": 1080, "fps": 30 },
46
+ "sources": [{ "id": "base", "path": "sources/base.mp4" }],
47
+ "tracks": [
48
+ { "id": "v1", "lane": "visual", "items": [
49
+ { "id": "base", "at": 0, "duration": 450, "source": { "kind": "media", "src": "base", "in": 0, "out": 15 } }
50
+ ] }
51
+ ]
52
+ }
53
+ ```
54
+
55
+ 1. `$SKILL_DIR/templates/` から構成を選ぶ。紙から画面へ入るなら `paper-to-browser`、ブラウザから会話へ渡すなら `browser-to-chat`、歩いて室内へ着くなら `street-to-room`。
56
+ 2. 選んだ JSON の `sampleScript` を `$PROJECT/planning/script.json` に写す。停留所の `label`、`dwell`(秒)、`asset`、world ごとの上書き、`carry` をブリーフに合わせて変更する。総尺は `Σ dwell + Σ 辺の尺` で求める。辺の尺は `move` 0.75 秒、`portal` 1.2 秒、`cut` 0.8 秒。`script.worlds.<worldId>.label` で表示名、`.palette` で `background` / `dots` / `accent` / `haze` を上書きできる。world id と stop id は内部キーなので、表示名は `label` で言い換えてよい。各 world の stop 数と内部 id は変えない。
57
+ 3. 台本をスキル側へ置かず、次の形で展開する。
58
+
59
+ ```sh
60
+ PROJECT=<project>
61
+ SKILL_DIR=<このスキルのディレクトリの絶対パス>
62
+ node "$SKILL_DIR/bin/expand-template.mjs" \
63
+ "$SKILL_DIR/templates/paper-to-browser.json" \
64
+ "$PROJECT/planning/script.json" \
65
+ --out "$PROJECT/planning/world-map.json"
66
+ ```
67
+
68
+ expand は最初の build を可能にするため、portal に 0.18 秒、cut に 0.24 秒の**有限の暫定値**を `cover` として入れる。テンプレートの `edges[].cover` でも 0〜0.4 秒の範囲で上書きできるが、最終的には preview の実測値を正とする。
69
+ 4. `akari world check "$PROJECT"` を実行し、エラーが 0 件になるまで台本を直して再展開する。
70
+ 5. 素材は、世界観の束(例: Pop Motion ワールド対応版)が持つ **背景 / 飛び込み口 / モチーフ** の 3 層で考える。背景で world ごとの材質を作り、飛び込み口で portal の通過を読ませ、モチーフを各停留所へ置く。必要な素材は `akari assets fetch <id>` で取得でき、`akari world build` がプロジェクト内の素材を解決する。
71
+ 6. `planning/world-items.json` を作り、素材を zone に対応づける。`asset` は必ず `overlay/<id>` と書く。`offset`、`scale`、`vars` は任意。
72
+
73
+ ```json
74
+ {
75
+ "schemaVersion": 1,
76
+ "items": [
77
+ {
78
+ "id": "arrival-motif",
79
+ "zone": "paper-note",
80
+ "asset": "overlay/<使う素材の id>",
81
+ "offset": [-240, -180],
82
+ "scale": 0.85,
83
+ "vars": { "accent": "#4285f4" }
84
+ }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ `asset` の `<使う素材の id>` は実際に使う素材の id に置き換え、`zone` は `planning/world-map.json` の zone id と一致させる。同じ zone の items は配列順に重なり、後ろの item が上に乗る。素材ごとに基準点と既定サイズが異なるため、`meta.json` / `fragment.html` を実測して `offset` を決める。詳しくは [world.md の「構図の目安」](world.md#構図の目安) を見る。
90
+ 7. `akari world build "$PROJECT"` を実行する。`overlays/world.html` と `edit.json` の world item が生成される。
91
+ 8. `akari world preview "$PROJECT" --measure` を実行して `planning/world-map.json` の cover を実測値へ置き換える。生成済みの `overlays/world.html` は暫定値のままなので、`akari world build "$PROJECT"` をもう一度実行し、`akari world check "$PROJECT" --strict` を通す。順番は **build → preview --measure → build → check --strict**。
92
+ 9. `akari world overview "$PROJECT"` を実行し、地図を人に見せる。停留所の順、世界境界、carry、portal/cut の位置が意図どおりか確認する。
93
+
94
+ 判断に迷ったら [world.md](world.md) の型を使う。概念と地図タブの読み方は [guide.md](guide.md) を見る。
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const usage = "使い方: node bin/expand-template.mjs <template.json> <script.json> --out planning/world-map.json";
7
+ const args = process.argv.slice(2);
8
+ const outIndex = args.indexOf("--out");
9
+ if (args.length !== 4 || outIndex !== 2 || !args[3]) fail(usage);
10
+
11
+ const templatePath = path.resolve(args[0]);
12
+ const scriptPath = path.resolve(args[1]);
13
+ const outPath = path.resolve(args[3]);
14
+
15
+ try {
16
+ const template = JSON.parse(await readFile(templatePath, "utf8"));
17
+ const script = JSON.parse(await readFile(scriptPath, "utf8"));
18
+ const map = expand(template, script);
19
+ await mkdir(path.dirname(outPath), { recursive: true });
20
+ await writeFile(outPath, `${JSON.stringify(map, null, 2)}\n`, "utf8");
21
+ process.stdout.write(`${outPath}\n`);
22
+ } catch (error) {
23
+ fail(error instanceof Error ? error.message : String(error));
24
+ }
25
+
26
+ export function expand(template, script) {
27
+ if (!record(template) || template.schemaVersion !== 1 || template.for !== "world-map") throw new Error("template は schemaVersion: 1 / for: world-map である必要があります");
28
+ if (!Array.isArray(template.worlds) || !Array.isArray(template.stops) || !Array.isArray(template.edges)) throw new Error("template に worlds / stops / edges が必要です");
29
+ if (!record(template.layout)) throw new Error("template.layout が必要です");
30
+ if (!record(script) || !Array.isArray(script.stops)) throw new Error("script.stops が必要です");
31
+ if (script.stops.length !== template.stops.length) throw new Error("script.stops は template.stops と同じ件数・順序にしてください");
32
+ if (template.edges.length !== Math.max(0, template.stops.length - 1)) throw new Error("template.edges は stops - 1 件必要です");
33
+
34
+ const layout = normalizeLayout(template.layout);
35
+ const worldIds = new Set();
36
+ for (const world of template.worlds) {
37
+ if (!string(world?.id) || worldIds.has(world.id)) throw new Error("template.worlds の id は空でない一意な文字列です");
38
+ worldIds.add(world.id);
39
+ }
40
+
41
+ const pairedStops = template.stops.map((stop, index) => {
42
+ const authored = script.stops[index];
43
+ if (!record(stop) || !record(authored) || authored.id !== stop.id) throw new Error(`script.stops[${index}].id は ${stop?.id} にしてください`);
44
+ if (!worldIds.has(stop.world)) throw new Error(`stop ${stop.id} が未定義の world を参照しています`);
45
+ if (!string(authored.label) || !positive(authored.dwell) || !string(authored.asset)) throw new Error(`script stop ${stop.id} には label / 正の dwell / asset が必要です`);
46
+ const scale = stop.scale ?? 1.1;
47
+ if (!finite(scale) || scale < 1 || scale > 1.3) throw new Error(`stop ${stop.id} の scale は 1.0〜1.3 です`);
48
+ return { skeleton: stop, authored, scale };
49
+ });
50
+
51
+ const stopsByWorld = new Map(template.worlds.map((world) => [world.id, []]));
52
+ for (const pair of pairedStops) stopsByWorld.get(pair.skeleton.world).push(pair);
53
+ for (const [worldId, stops] of stopsByWorld) if (stops.length < 2) throw new Error(`world ${worldId} には stop が 2 件以上必要です`);
54
+
55
+ let worldX = layout.origin[0];
56
+ const boundsByWorld = new Map();
57
+ const worlds = template.worlds.map((world) => {
58
+ const count = stopsByWorld.get(world.id).length;
59
+ const width = Math.max(layout.worldWidth, layout.padding * 2 + layout.stopSpacing * (count - 1));
60
+ const bounds = [worldX, layout.origin[1], width, layout.worldHeight];
61
+ boundsByWorld.set(world.id, bounds);
62
+ worldX += width + layout.worldGap;
63
+ const override = record(script.worlds?.[world.id]) ? script.worlds[world.id] : {};
64
+ const palette = { ...world.palette, ...(record(override.palette) ? override.palette : {}) };
65
+ validatePalette(palette, world.id);
66
+ return {
67
+ id: world.id,
68
+ label: string(override.label) ? override.label : world.label,
69
+ palette,
70
+ flat: { bounds, pattern: world.pattern }
71
+ };
72
+ });
73
+
74
+ const zoneCoordinates = new Map();
75
+ for (const world of template.worlds) {
76
+ const bounds = boundsByWorld.get(world.id);
77
+ stopsByWorld.get(world.id).forEach((pair, index) => {
78
+ const x = bounds[0] + layout.padding + layout.stopSpacing * index;
79
+ const y = bounds[1] + bounds[3] / 2 + (index % 2 === 0 ? -layout.stopOffsetY : layout.stopOffsetY);
80
+ zoneCoordinates.set(pair.skeleton.id, [x, y]);
81
+ });
82
+ }
83
+
84
+ const zones = pairedStops.map(({ skeleton, authored }) => ({
85
+ id: skeleton.id,
86
+ label: authored.label,
87
+ world: skeleton.world,
88
+ c: zoneCoordinates.get(skeleton.id)
89
+ }));
90
+
91
+ let clockMs = 0;
92
+ const cameraStops = pairedStops.map(({ skeleton, authored, scale }, index) => {
93
+ const at = clockMs;
94
+ const leave = at + milliseconds(authored.dwell, `stop ${skeleton.id} dwell`);
95
+ clockMs = leave;
96
+ if (index < template.edges.length) clockMs += edgeDuration(template.edges[index].type);
97
+ const [x, y] = zoneCoordinates.get(skeleton.id);
98
+ return { id: skeleton.id, world: skeleton.world, at: seconds(at), leave: seconds(leave), c: [x, y, scale] };
99
+ });
100
+
101
+ const carry = uniqueStrings(script.carry);
102
+ const edges = template.edges.map((edge, index) => {
103
+ const from = cameraStops[index];
104
+ const to = cameraStops[index + 1];
105
+ if (!record(edge) || !["move", "portal", "cut"].includes(edge.type)) throw new Error(`template.edges[${index}].type が未定義です`);
106
+ const crossesWorld = from.world !== to.world;
107
+ if (!crossesWorld && edge.type !== "move") throw new Error(`同じ world の edge ${index} は move にしてください`);
108
+ if (crossesWorld && edge.type === "move") throw new Error(`世界をまたぐ edge ${index} は portal または cut にしてください`);
109
+ if (crossesWorld && carry.length === 0) throw new Error("世界をまたぐ辺には script.carry が 1 件以上必要です");
110
+ // build / preview の宣言は有限の cover を要求する。ここでは build 可能な暫定値を置き、
111
+ // preview --measure の実測値で置き換える。
112
+ const cover = transitionCover(edge, index);
113
+ const result = {
114
+ id: `${from.id}-to-${to.id}`,
115
+ from: from.id,
116
+ to: to.id,
117
+ type: edge.type,
118
+ t0: from.leave,
119
+ t1: to.at,
120
+ transition: edge.type === "move" ? { kind: "none", cover } : { kind: edge.type === "cut" ? "mist" : "dive", cover }
121
+ };
122
+ if (edge.type !== "move") {
123
+ if (!string(edge.via)) throw new Error(`edge ${index} の via が必要です`);
124
+ result.via = edge.via;
125
+ result.switchTime = seconds(Math.round((milliseconds(from.leave) + milliseconds(to.at)) / 2));
126
+ }
127
+ if (crossesWorld) result.carry = carry;
128
+ return result;
129
+ });
130
+
131
+ return {
132
+ schemaVersion: 3,
133
+ kind: "flat",
134
+ worlds,
135
+ zones,
136
+ cameraStops,
137
+ edges,
138
+ retainedNodes: carry,
139
+ inventory: pairedStops.map(({ skeleton, authored }) => ({ id: `${skeleton.id}-asset`, zone: skeleton.id, asset: `overlay/${authored.asset}` }))
140
+ };
141
+ }
142
+
143
+ function normalizeLayout(value) {
144
+ const result = {
145
+ origin: value.origin,
146
+ worldWidth: value.worldWidth,
147
+ worldHeight: value.worldHeight,
148
+ worldGap: value.worldGap,
149
+ stopSpacing: value.stopSpacing,
150
+ padding: value.padding,
151
+ stopOffsetY: value.stopOffsetY ?? 48
152
+ };
153
+ if (!Array.isArray(result.origin) || result.origin.length !== 2 || !result.origin.every(finite)) throw new Error("layout.origin は数値 2 要素です");
154
+ for (const key of ["worldWidth", "worldHeight", "worldGap", "stopSpacing", "padding"]) if (!positive(result[key])) throw new Error(`layout.${key} は正の数です`);
155
+ if (!finite(result.stopOffsetY) || result.stopOffsetY < 0) throw new Error("layout.stopOffsetY は 0 以上です");
156
+ return result;
157
+ }
158
+
159
+ function validatePalette(palette, worldId) {
160
+ for (const key of ["background", "dots", "accent"]) if (!/^#[0-9a-fA-F]{6}$/.test(palette?.[key])) throw new Error(`world ${worldId} の palette.${key} は 6 桁 hex です`);
161
+ if (palette.haze !== undefined && !/^#[0-9a-fA-F]{6}$/.test(palette.haze)) throw new Error(`world ${worldId} の palette.haze は 6 桁 hex です`);
162
+ }
163
+
164
+ function edgeDuration(type) {
165
+ if (type === "move") return 750;
166
+ if (type === "portal") return 1200;
167
+ if (type === "cut") return 800;
168
+ throw new Error(`edge type が未定義です: ${type}`);
169
+ }
170
+
171
+ function transitionCover(edge, index) {
172
+ const fallback = edge.type === "move" ? 0 : edge.type === "portal" ? 0.18 : 0.24;
173
+ if (edge.cover === undefined) return fallback;
174
+ if (!finite(edge.cover) || edge.cover < 0 || edge.cover > 0.4) {
175
+ throw new Error(`template.edges[${index}].cover は 0 以上 0.4 以下の有限数です`);
176
+ }
177
+ if (edge.type === "move" && edge.cover !== 0) {
178
+ throw new Error(`template.edges[${index}].cover は move では 0 にしてください`);
179
+ }
180
+ return edge.cover;
181
+ }
182
+
183
+ function milliseconds(value, label = "time") {
184
+ if (!finite(value)) throw new Error(`${label} は有限数です`);
185
+ return Math.round(value * 1000 / 50) * 50;
186
+ }
187
+
188
+ function seconds(value) { return value / 1000; }
189
+ function finite(value) { return typeof value === "number" && Number.isFinite(value); }
190
+ function positive(value) { return finite(value) && value > 0; }
191
+ function string(value) { return typeof value === "string" && value.length > 0; }
192
+ function record(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
193
+ function uniqueStrings(value) { return Array.isArray(value) ? [...new Set(value.filter(string))] : []; }
194
+
195
+ function fail(message) {
196
+ process.stderr.write(`${message}\n`);
197
+ process.exit(2);
198
+ }
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
8
+ import test from "node:test";
9
+
10
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
11
+ const skillDir = path.resolve(testDir, "../..");
12
+ const repoRoot = path.resolve(skillDir, "../..");
13
+ const expandBin = path.join(skillDir, "bin", "expand-template.mjs");
14
+ const akariBin = process.env.AKARI_BIN || path.join(repoRoot, "packages", "akari-launcher", "bin", "akari.mjs");
15
+ const schemasBin = process.env.AKARI_SCHEMAS_BIN || path.join(repoRoot, "packages", "schemas", "bin", "validate-world-map.mjs");
16
+ const templates = ["paper-to-browser", "browser-to-chat", "street-to-room"];
17
+ const WORLD_RUNTIME_KEYS = {
18
+ worlds: new Set(["id", "label", "palette", "flat"]),
19
+ zones: new Set(["id", "label", "world", "c"]),
20
+ cameraStops: new Set(["id", "world", "at", "leave", "c"]),
21
+ edges: new Set(["id", "from", "to", "type", "t0", "t1", "switchTime", "transition", "via", "carry", "easing"]),
22
+ transition: new Set(["kind", "cover"])
23
+ };
24
+
25
+ for (const name of templates) {
26
+ test(`${name}: sampleScript を決定論的に展開して検査できる`, async (t) => {
27
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "design-world-test-"));
28
+ t.after(() => rm(temporary, { recursive: true, force: true }));
29
+ const templatePath = path.join(skillDir, "templates", `${name}.json`);
30
+ const template = JSON.parse(await readFile(templatePath, "utf8"));
31
+ const scriptPath = path.join(temporary, "script.json");
32
+ await writeFile(scriptPath, `${JSON.stringify(template.sampleScript, null, 2)}\n`);
33
+
34
+ const firstProject = path.join(temporary, "first");
35
+ const secondProject = path.join(temporary, "second");
36
+ const firstOut = path.join(firstProject, "planning", "world-map.json");
37
+ const secondOut = path.join(secondProject, "planning", "world-map.json");
38
+ await mkdir(path.dirname(firstOut), { recursive: true });
39
+ await mkdir(path.dirname(secondOut), { recursive: true });
40
+ run(process.execPath, [expandBin, templatePath, scriptPath, "--out", firstOut]);
41
+ run(process.execPath, [expandBin, templatePath, scriptPath, "--out", secondOut]);
42
+
43
+ const [first, second] = await Promise.all([readFile(firstOut), readFile(secondOut)]);
44
+ assert.deepEqual(first, second, "同じ入力の world-map.json はバイト一致する");
45
+ const map = JSON.parse(first.toString("utf8"));
46
+ assert.equal(map.schemaVersion, 3);
47
+ assert.equal(map.kind, "flat");
48
+ assert.equal(map.edges.length, map.cameraStops.length - 1);
49
+ assert.deepEqual(new Set(map.zones.map((zone) => zone.id)), new Set(map.cameraStops.map((stop) => stop.id)));
50
+ for (const world of map.worlds) assert.ok(map.zones.filter((zone) => zone.world === world.id).length >= 2);
51
+ for (const world of map.worlds) assertAllowedKeys(world, WORLD_RUNTIME_KEYS.worlds, "world");
52
+ for (const zone of map.zones) assertAllowedKeys(zone, WORLD_RUNTIME_KEYS.zones, "zone");
53
+ for (const stop of map.cameraStops) assertAllowedKeys(stop, WORLD_RUNTIME_KEYS.cameraStops, "cameraStop");
54
+ const outputWidth = 1920;
55
+ const outputHeight = 1080;
56
+ for (const stop of map.cameraStops) {
57
+ const world = map.worlds.find((candidate) => candidate.id === stop.world);
58
+ assert.ok(world, `${stop.id}: world ${stop.world} が存在する`);
59
+ const [boundsLeft, boundsTop, boundsWidth, boundsHeight] = world.flat.bounds;
60
+ const boundsRight = boundsLeft + boundsWidth;
61
+ const boundsBottom = boundsTop + boundsHeight;
62
+ const [x, y, scale] = stop.c;
63
+ const frameLeft = x - (outputWidth / 2) / scale;
64
+ const frameRight = x + (outputWidth / 2) / scale;
65
+ const frameTop = y - (outputHeight / 2) / scale;
66
+ const frameBottom = y + (outputHeight / 2) / scale;
67
+ assert.ok(frameLeft >= boundsLeft, `${stop.id}: 撮影枠の左が bounds から ${(boundsLeft - frameLeft).toFixed(1)} world px はみ出す`);
68
+ assert.ok(frameRight <= boundsRight, `${stop.id}: 撮影枠の右が bounds から ${(frameRight - boundsRight).toFixed(1)} world px はみ出す`);
69
+ assert.ok(frameTop >= boundsTop, `${stop.id}: 撮影枠の上が bounds から ${(boundsTop - frameTop).toFixed(1)} world px はみ出す`);
70
+ assert.ok(frameBottom <= boundsBottom, `${stop.id}: 撮影枠の下が bounds から ${(frameBottom - boundsBottom).toFixed(1)} world px はみ出す`);
71
+ }
72
+ for (const edge of map.edges) {
73
+ assertAllowedKeys(edge, WORLD_RUNTIME_KEYS.edges, "edge");
74
+ assertAllowedKeys(edge.transition, WORLD_RUNTIME_KEYS.transition, "transition");
75
+ assert.ok(Number.isFinite(edge.transition.cover), "transition.cover は有限数");
76
+ assert.ok(edge.transition.cover >= 0 && edge.transition.cover <= 0.4, "transition.cover は 0〜0.4");
77
+ assert.equal(edge.transition.cover, edge.type === "move" ? 0 : edge.type === "portal" ? 0.18 : 0.24, `${edge.type} の暫定 cover`);
78
+ }
79
+
80
+ await t.test("validate-world-map", { skip: !existsSync(schemasBin) && "schema validator が見つからない配布形" }, () => {
81
+ run(schemasBin, [firstProject]);
82
+ });
83
+ await t.test("akari world check", { skip: !existsSync(akariBin) && "akari CLI が見つからない配布形" }, () => {
84
+ run(akariBin, ["world", "check", firstProject]);
85
+ });
86
+ });
87
+ }
88
+
89
+ test("template の cover 上書きを優先し、範囲外を拒否する", async (t) => {
90
+ const { temporary, original, templatePath, scriptPath, outPath } = await fixture(t);
91
+ assert.ok(temporary);
92
+ original.edges[1].cover = 0.31;
93
+ await writeFile(templatePath, `${JSON.stringify(original, null, 2)}\n`);
94
+ run(process.execPath, [expandBin, templatePath, scriptPath, "--out", outPath]);
95
+ const map = JSON.parse(await readFile(outPath, "utf8"));
96
+ assert.equal(map.edges[1].transition.cover, 0.31);
97
+
98
+ original.edges[1].cover = 0.41;
99
+ await writeFile(templatePath, `${JSON.stringify(original, null, 2)}\n`);
100
+ const failed = spawnSync(process.execPath, [expandBin, templatePath, scriptPath, "--out", outPath], { encoding: "utf8" });
101
+ assert.equal(failed.status, 2);
102
+ assert.match(failed.stderr, /0 以上 0\.4 以下の有限数/);
103
+ });
104
+
105
+ test("script.stops の件数不一致を exit 2 で拒否する", async (t) => {
106
+ const { original, templatePath, scriptPath, outPath } = await fixture(t);
107
+ original.sampleScript.stops.pop();
108
+ await writeFile(scriptPath, `${JSON.stringify(original.sampleScript, null, 2)}\n`);
109
+ const failed = spawnSync(process.execPath, [expandBin, templatePath, scriptPath, "--out", outPath], { encoding: "utf8" });
110
+ assert.equal(failed.status, 2);
111
+ assert.match(failed.stderr, /同じ件数・順序/);
112
+ });
113
+
114
+ test("move の 0 以外の cover を exit 2 で拒否する", async (t) => {
115
+ const { original, templatePath, scriptPath, outPath } = await fixture(t);
116
+ original.edges[0].cover = 0.1;
117
+ await writeFile(templatePath, `${JSON.stringify(original, null, 2)}\n`);
118
+ const failed = spawnSync(process.execPath, [expandBin, templatePath, scriptPath, "--out", outPath], { encoding: "utf8" });
119
+ assert.equal(failed.status, 2);
120
+ assert.match(failed.stderr, /move では 0/);
121
+ });
122
+
123
+ test("中立な via と asset id を出力へ保つ", async (t) => {
124
+ const { templatePath, scriptPath, outPath } = await fixture(t);
125
+ run(process.execPath, [expandBin, templatePath, scriptPath, "--out", outPath]);
126
+ const map = JSON.parse(await readFile(outPath, "utf8"));
127
+ assert.equal(map.edges.find((edge) => edge.type === "portal").via, "paper-portal");
128
+ assert.ok(map.inventory.every((item) => /^overlay\/(signpost|portal-frame)$/.test(item.asset)));
129
+ });
130
+
131
+ async function fixture(t) {
132
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "design-world-fixture-"));
133
+ t.after(() => rm(temporary, { recursive: true, force: true }));
134
+ const templatePath = path.join(temporary, "template.json");
135
+ const scriptPath = path.join(temporary, "script.json");
136
+ const outPath = path.join(temporary, "planning", "world-map.json");
137
+ const original = JSON.parse(await readFile(path.join(skillDir, "templates", "paper-to-browser.json"), "utf8"));
138
+ await writeFile(templatePath, `${JSON.stringify(original, null, 2)}\n`);
139
+ await writeFile(scriptPath, `${JSON.stringify(original.sampleScript, null, 2)}\n`);
140
+ return { temporary, original, templatePath, scriptPath, outPath };
141
+ }
142
+
143
+ function run(command, args) {
144
+ const result = spawnSync(command, args, { encoding: "utf8" });
145
+ assert.equal(result.error, undefined, result.error?.message);
146
+ assert.equal(result.status, 0, `${command} ${args.join(" ")}\n${result.stdout}\n${result.stderr}`);
147
+ }
148
+
149
+ function assertAllowedKeys(value, allowed, label) {
150
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
151
+ assert.deepEqual(unknown, [], `${label} に world-runtime の未知キーを出さない`);
152
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "main": "expand-template.test.mjs"
4
+ }
@@ -0,0 +1,17 @@
1
+ # ワールド編 — ワールドを作って地図で見る
2
+
3
+ ワールドは、動画の背景・停留所・移動を一枚の地図として先に決める方法です。正本は `planning/world-map.json`。同じデータから映像のカメラと overview の地図を作るため、映像を直したのに地図だけ古い、というずれを避けられます。v0 は平面の `flat` ワールドが対象です。
4
+
5
+ 一つの world は同じ材質と座標のまとまりです。各 world に「読ませる一枚」である停留所を 2 件以上置きます。背景や規則が変わる所で world を分け、同じ world 内はカメラ移動でつなぎます。
6
+
7
+ 素材は、世界観の束(例: Pop Motion ワールド対応版)が持つ **背景 / 飛び込み口 / モチーフ** の 3 層として選びます。背景は世界の材質、飛び込み口は境界を越える理由、モチーフは停留所で読む内容を担います。
8
+
9
+ ## 辺の 3 型
10
+
11
+ `move` は同じ世界の連続移動です。`portal` は窓、扉、紙の穴などを通って別世界へ入り、世界境界の既定にします。`cut` は時間を縮めて別世界へ飛ぶ手段で、必ず霧が覆う間に切り替えます。世界をまたぐ辺には、主人公やカードなど前後に残す物を `carry` として宣言します。
12
+
13
+ `cover` は画面が世界の切り替えを隠した時間です。expand は最初の build を通すため、portal に 0.18 秒、cut に 0.24 秒の有限な暫定値を入れます。`build` → `preview --measure` で実測した後、その値を反映するためもう一度 `build` し、`check --strict` を通します。実測値が正で、許される範囲は 0〜0.4 秒です。move は常に `cover: 0` とします。
14
+
15
+ ## 地図タブの見る所
16
+
17
+ 地図では world の面、停留所の順番、辺の種類、camera stop の時刻を読みます。最初に、各 world に入口と出口があるかを見ます。次に、同じ world 内が move、世界境界が portal または mist の cut かを見ます。最後に carry が境界の前後で必要な物だけになっているかを確かめます。地図は読み取り専用で、修正は台本または `planning/world-map.json` に戻して行います。
@@ -0,0 +1,39 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "browser-to-chat",
4
+ "for": "world-map",
5
+ "summary": "ブラウザから portal で中間世界へ入り、霧の cut でチャットへ渡す構成。\n製品ページ、処理の舞台、会話の結果を三段で見せる動画向け。",
6
+ "layout": { "origin": [0, 0], "worldWidth": 3300, "worldHeight": 1700, "worldGap": 500, "stopSpacing": 2000, "padding": 1100, "stopOffsetY": 170 },
7
+ "worlds": [
8
+ { "id": "browser", "label": "ブラウザ", "pattern": "grid", "palette": { "background": "#eef6ff", "dots": "#b5ccea", "accent": "#4285f4", "haze": "#ffffff" } },
9
+ { "id": "bridge", "label": "接続路", "pattern": "dots", "palette": { "background": "#fff6dd", "dots": "#dfc990", "accent": "#ffd449", "haze": "#fffdf7" } },
10
+ { "id": "chat", "label": "チャット", "pattern": "none", "palette": { "background": "#f5efff", "dots": "#c8b8e8", "accent": "#ae91f3", "haze": "#fffaff" } }
11
+ ],
12
+ "stops": [
13
+ { "id": "browser-home", "world": "browser", "scale": 1.08 },
14
+ { "id": "browser-action", "world": "browser", "scale": 1.24 },
15
+ { "id": "bridge-entry", "world": "bridge", "scale": 1.2 },
16
+ { "id": "bridge-exit", "world": "bridge", "scale": 1.08 },
17
+ { "id": "chat-question", "world": "chat", "scale": 1.2 },
18
+ { "id": "chat-answer", "world": "chat", "scale": 1.06 }
19
+ ],
20
+ "edges": [
21
+ { "type": "move" },
22
+ { "type": "portal", "via": "screen-portal", "transition": "dive" },
23
+ { "type": "move" },
24
+ { "type": "cut", "via": "mist-bridge", "transition": "mist" },
25
+ { "type": "move" }
26
+ ],
27
+ "sampleScript": {
28
+ "worlds": {},
29
+ "stops": [
30
+ { "id": "browser-home", "label": "入口ページ", "dwell": 2.0, "asset": "portal-frame" },
31
+ { "id": "browser-action", "label": "操作を選ぶ", "dwell": 2.1, "asset": "signpost" },
32
+ { "id": "bridge-entry", "label": "接続路へ", "dwell": 1.6, "asset": "signpost" },
33
+ { "id": "bridge-exit", "label": "応答を待つ", "dwell": 1.7, "asset": "signpost" },
34
+ { "id": "chat-question", "label": "問いかけ", "dwell": 2.2, "asset": "signpost" },
35
+ { "id": "chat-answer", "label": "返事が届く", "dwell": 2.4, "asset": "signpost" }
36
+ ],
37
+ "carry": ["cursor-guide"]
38
+ }
39
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "paper-to-browser",
4
+ "for": "world-map",
5
+ "summary": "紙面を読み進め、紙の窓からブラウザ世界へ潜る構成。\n手書きの導入から製品画面や Web の説明へ自然につなぐ動画向け。",
6
+ "layout": { "origin": [0, 0], "worldWidth": 3300, "worldHeight": 1700, "worldGap": 500, "stopSpacing": 2000, "padding": 1100, "stopOffsetY": 170 },
7
+ "worlds": [
8
+ { "id": "paper", "label": "紙の世界", "pattern": "dots", "palette": { "background": "#fffdf7", "dots": "#d8cfbd", "accent": "#ee82df", "haze": "#fff4d8" } },
9
+ { "id": "browser", "label": "ブラウザの世界", "pattern": "grid", "palette": { "background": "#edf5ff", "dots": "#afc7e8", "accent": "#4285f4", "haze": "#f8fbff" } }
10
+ ],
11
+ "stops": [
12
+ { "id": "paper-note", "world": "paper", "scale": 1.12 },
13
+ { "id": "paper-window", "world": "paper", "scale": 1.28 },
14
+ { "id": "browser-arrival", "world": "browser", "scale": 1.2 },
15
+ { "id": "browser-detail", "world": "browser", "scale": 1.1 },
16
+ { "id": "browser-summary", "world": "browser", "scale": 1.0 }
17
+ ],
18
+ "edges": [
19
+ { "type": "move" },
20
+ { "type": "portal", "via": "paper-portal", "transition": "dive" },
21
+ { "type": "move" },
22
+ { "type": "move" }
23
+ ],
24
+ "sampleScript": {
25
+ "worlds": {
26
+ "paper": { "label": "メモの平原" },
27
+ "browser": { "label": "青い画面の街" }
28
+ },
29
+ "stops": [
30
+ { "id": "paper-note", "label": "最初のひとこと", "dwell": 2.2, "asset": "signpost" },
31
+ { "id": "paper-window", "label": "紙の入口", "dwell": 1.8, "asset": "portal-frame" },
32
+ { "id": "browser-arrival", "label": "画面に到着", "dwell": 2.0, "asset": "portal-frame" },
33
+ { "id": "browser-detail", "label": "機能を見る", "dwell": 2.4, "asset": "signpost" },
34
+ { "id": "browser-summary", "label": "次へ進む", "dwell": 2.0, "asset": "signpost" }
35
+ ],
36
+ "carry": ["guide-card"]
37
+ }
38
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "street-to-room",
4
+ "for": "world-map",
5
+ "summary": "街を move で歩き、門を portal として通り、短い中間世界から部屋へ着く構成。\n移動の実感を主役にしながら屋外から室内へ導く動画向け。",
6
+ "layout": { "origin": [0, 0], "worldWidth": 3300, "worldHeight": 1700, "worldGap": 500, "stopSpacing": 2000, "padding": 1100, "stopOffsetY": 170 },
7
+ "worlds": [
8
+ { "id": "street", "label": "通り", "pattern": "grid", "palette": { "background": "#edf8f2", "dots": "#a9cfbb", "accent": "#2fc983", "haze": "#f7fff9" } },
9
+ { "id": "doorway", "label": "玄関", "pattern": "dots", "palette": { "background": "#fff3dc", "dots": "#d8be8f", "accent": "#fa8072", "haze": "#fffaf0" } },
10
+ { "id": "room", "label": "部屋", "pattern": "none", "palette": { "background": "#f6f0ff", "dots": "#cbbce6", "accent": "#ae91f3", "haze": "#fffbff" } }
11
+ ],
12
+ "stops": [
13
+ { "id": "street-start", "world": "street", "scale": 1.0 },
14
+ { "id": "street-corner", "world": "street", "scale": 1.14 },
15
+ { "id": "doorway-outside", "world": "doorway", "scale": 1.22 },
16
+ { "id": "doorway-inside", "world": "doorway", "scale": 1.12 },
17
+ { "id": "room-table", "world": "room", "scale": 1.18 },
18
+ { "id": "room-seat", "world": "room", "scale": 1.04 }
19
+ ],
20
+ "edges": [
21
+ { "type": "move" },
22
+ { "type": "portal", "via": "doorway-portal", "transition": "dive" },
23
+ { "type": "move" },
24
+ { "type": "cut", "via": "mist-threshold", "transition": "mist" },
25
+ { "type": "move" }
26
+ ],
27
+ "sampleScript": {
28
+ "worlds": {},
29
+ "stops": [
30
+ { "id": "street-start", "label": "歩きはじめ", "dwell": 1.8, "asset": "signpost" },
31
+ { "id": "street-corner", "label": "角を曲がる", "dwell": 1.7, "asset": "signpost" },
32
+ { "id": "doorway-outside", "label": "入口を見つける", "dwell": 2.0, "asset": "portal-frame" },
33
+ { "id": "doorway-inside", "label": "敷居を越える", "dwell": 1.6, "asset": "signpost" },
34
+ { "id": "room-table", "label": "机に着く", "dwell": 2.1, "asset": "signpost" },
35
+ { "id": "room-seat", "label": "落ち着く", "dwell": 2.3, "asset": "signpost" }
36
+ ],
37
+ "carry": ["traveler-token"]
38
+ }
39
+ }
@@ -0,0 +1,62 @@
1
+ # ワールド設計の判断の型
2
+
3
+ ## 世界を切る
4
+
5
+ 背景の物理、画面の材質、時間や場所の規則が変わる所を world の境界にする。同じ机の上を巡るだけなら一つ、紙面からブラウザ画面へ入るなら二つに分ける。世界を増やす前に「背景・座標系・物の残り方のどれが変わるか」を一文で説明できるか確かめる。
6
+
7
+ 各 world には入口と出口に相当する停留所を最低 2 件置く。停留所は「カメラを止めて読ませる一枚」であり、飾りだけの場所は停留所にしない。
8
+
9
+ ## 辺を選ぶ
10
+
11
+ - `move`: 同じ world の連続移動。距離感を見せる。既定 0.75 秒。
12
+ - `portal`: 別 world へ連続して入る。入口の形や材質を見せたいときの既定。既定 1.2 秒、`transition.kind` は `dive`。
13
+ - `cut`: 別 world へ意味で飛ぶ。時間を縮める必要があるときだけ使い、霧が覆う間に切り替える。既定 0.8 秒、`transition.kind` は `mist`。
14
+
15
+ portal の `via` は、通過が画面で理解できる窓・扉・穴などの演出を表す空でない論理名にする。たとえば飛び込み口の素材 id が `paper-portal` なら、同じ名前を `via` に使える。cut の `via` は霧を担う素材または演出の論理名にする。
16
+
17
+ ## carry を決める
18
+
19
+ 世界境界の前後を同じ話として認識させる物だけを carry にする。主人公、手に持つカード、視線を導く印など、境界後にも見える必要がある物を選ぶ。世界をまたぐ辺の carry は空にせず、全 id を `retainedNodes` に集約する。背景や、その world に置き去りにする小物は持ち越さない。
20
+
21
+ ## 時間と cover
22
+
23
+ 停留所の滞在は、短い絵なら 1.5〜2.0 秒、見出しを読むなら 2.0〜3.0 秒、複数要素を読むなら 3.0〜4.0 秒を目安にする。まず読める尺を置き、移動を速めて総尺を合わせる。
24
+
25
+ expand は最初の build を可能にするため、portal の `transition.cover` に 0.18 秒、cut に 0.24 秒の有限な暫定値を置く。`akari world preview --measure` で測った値が正で、0〜0.4 秒の範囲に収める。計測後は `akari world build` を再実行して `overlays/world.html` に実測値を反映する。move は常に `{ "kind": "none", "cover": 0 }` とする。
26
+
27
+ ## 構図の目安
28
+
29
+ 出力幅を `W`、出力高を `H`、停留所の `c` を `[x, y, scale]` とすると、画面に映る world の撮影枠は world px で次のようになる。
30
+
31
+ ```text
32
+ frame = [x - (W/2)/scale, y - (H/2)/scale, W/scale, H/scale]
33
+ ```
34
+
35
+ 既定出力の `W=1920`、`H=1080` では次が目安になる。
36
+
37
+ | scale | 撮影枠の幅 | 撮影枠の高さ | 半幅 | 半高 |
38
+ |---:|---:|---:|---:|---:|
39
+ | 1.0 | 1920 | 1080 | 960 | 540 |
40
+ | 1.1 | 1745.5 | 981.8 | 872.7 | 490.9 |
41
+ | 1.2 | 1600 | 900 | 800 | 450 |
42
+ | 1.3 | 1476.9 | 830.8 | 738.5 | 415.4 |
43
+
44
+ 素材の既定サイズは各素材の `meta.json` / `fragment.html` の CSS 変数で決まる。使う素材の幅・高さと transform origin を実測してから `offset` を計算し、停留所 1 つには 2〜3 点を目安に置く。
45
+
46
+ ### offset の基準点
47
+
48
+ `world-items.json` の `offset` は停留所中心からの world px である。素材の基準点が中心なら狙った中心位置をそのまま指定する。基準点が左上なら、素材の幅を `w`、高さを `h`、素材の scale を `s`、狙った中心を `[cx, cy]` として次で換算する。
49
+
50
+ ```text
51
+ offset = [cx - w * s / 2, cy - h * s / 2]
52
+ ```
53
+
54
+ 安全余白は、素材の中心位置を `c`、scale 適用後の半幅・半高を `half`、停留所のカメラ倍率を `cameraScale` として、横を `|c.x| + half.width <= (960/cameraScale) - 120`、縦を `|c.y| + half.height <= (540/cameraScale) - 120` に収める。120 は画面端の安全余白である。収まらなければ素材の CSS 変数または `scale` で縮める。
55
+
56
+ テンプレートの既定 layout は、最小 scale 1.0 でも撮影枠と bounds の間に 140 world px の余白を残す。`padding`、`worldHeight`、`stopOffsetY` を変える場合は、余白を `M` として `padding >= (W/2)/scaleMin + M`、`worldHeight/2 >= stopOffsetY + (H/2)/scaleMin + M` を満たすこと。満たさないと停留所の画面端に世界外の白帯が出る。`bin/test` は各停留所の撮影枠が bounds 内にあることを検査する。
57
+
58
+ ### 隣の停留所の映り込み
59
+
60
+ 撮影枠の幅は `1920/scale` なので、`stopSpacing` より広いと隣の停留所の素材が映り込む。既定の `stopSpacing = 2000` は最小 scale 1.0 の撮影枠幅 1920 より広い。素材を停留所中心から `±(stopSpacing/2) = ±1000` の陣地に収めれば、隣の停留所の素材は映らない。`stopSpacing` を狭める場合は、素材の届く範囲も同じだけ狭める。
61
+
62
+ 説明役を停留所の左側 -400 前後、主役を右側 +400 前後へ置くと視線が左から右へ流れる。左上基準の素材は上の式で `offset` へ換算し、色の CSS 変数はその world の `palette.accent` に合わせる。
@@ -40,3 +40,42 @@
40
40
  `view` はカメラ位置に依存しない world → screen の affine `{ scale, ox, oy }`
41
41
  (`screen = ox + p * scale`)。`options.frame === true` なら現在の撮影枠を `#EE82DF` で重ねる。
42
42
  独自の地図描画を複製しない。
43
+
44
+ ## 地図タブからの書き戻し
45
+
46
+ flat の停留所は地図タブで ⌥ ドラッグして移動できる。書き戻しは
47
+ `akari world move-stop <project-root> --stop <id> --c x,y[,scale]` だけが行い、spatial には対応しない。
48
+ `world-map.json` は edit.json の履歴の外にあるため、undo / redo はない。
49
+
50
+ ## spatial world の build / preview
51
+
52
+ `kind: "spatial"` も flat と同じ `planning/world-map.json` と `camera(t)` を使う。
53
+ `akari world build <project-root>` は世界の床・遠景板・霧板・zone の目印を
54
+ `assets/world/world.glb` に焼き、eye / target を別々に補間した `TourCamera` と `Tour` clip を加える。
55
+ 床には `palette.dots` 由来の決定論的な格子を焼き、背景色と同色の world でも観察できる構造を保つ。
56
+ 同時に `overlays/world.html` へ、three-runtime が受理する次の宣言を生成し、edit.json の
57
+ visual lane へ id `world` で upsert する。
58
+
59
+ ```json
60
+ {
61
+ "model": "assets/world/world.glb",
62
+ "camera": { "fromModel": "TourCamera" },
63
+ "animationClip": "Tour",
64
+ "environment": { "intensity": 0, "exposure": 1 },
65
+ "lights": [],
66
+ "fog": { "color": "#ccd9e6", "near": 1, "far": 128 },
67
+ "background": { "color": "#182235" }
68
+ }
69
+ ```
70
+
71
+ `fog` は先頭 world の `palette.haze`、`background` は `palette.background` がある場合だけ
72
+ 生成される。値が無いときにキーを補わない。テロップと 2D 素材はこの three 断片へ入れず、
73
+ 別 overlay item として重ねる。
74
+
75
+ `akari world preview <project-root>` は flat と同じ rasterize 経路で stop と edge の代表 PNG を撮る。
76
+ `--measure` を付けると非 move edge を 30 Hz で走査し、全画素 RGB の標準偏差が 2 以下の
77
+ 一様な霧となった連続時間を `transition.cover` へ書き戻す。3D の cut は霧または遮蔽物を通し、
78
+ 平面ワイプにしない。
79
+
80
+ edit.json は version 2 が必要である。古い形式では build がファイルを書き換えずに停止するため、
81
+ 先に `akari migrate <project-root>` を実行して、専用コマンドの確認と退避バックアップを通す。