akari-video 0.1.0 → 0.1.2

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 (32) hide show
  1. package/README.md +17 -11
  2. package/package.json +2 -2
  3. package/src/cli.mjs +46 -11
  4. package/src/messages.mjs +8 -0
  5. package/src/path-lookup.mjs +23 -0
  6. package/vendor/packages/project-scaffold/src/index.mjs +21 -5
  7. package/vendor/packages/project-scaffold/test/opencode-scaffold.test.mjs +94 -0
  8. package/vendor/packages/project-scaffold/test/skill-adapter-link.test.mjs +7 -3
  9. package/vendor/packages/schemas/asset-meta.schema.json +12 -1
  10. package/vendor/packages/schemas/bin/validate-asset.mjs +22 -11
  11. package/vendor/packages/schemas/bin/validate-intake.mjs +0 -0
  12. package/vendor/packages/schemas/bin/validate-plan.mjs +0 -0
  13. package/vendor/packages/schemas/bin/validate-review.mjs +0 -0
  14. package/vendor/packages/schemas/edit.schema.json +1 -1
  15. package/vendor/packages/schemas/recipe.schema.json +1 -1
  16. package/vendor/packages/schemas/test/fixtures/asset/valid-library/{telop → overlay}/lower-third-clean/meta.json +1 -1
  17. package/vendor/packages/schemas/test/validate-asset.test.mjs +1 -1
  18. package/vendor/skills/bake-3d/SKILL.md +3 -3
  19. package/vendor/skills/create-project/SKILL.md +20 -0
  20. package/vendor/skills/edit-plan/expression-selection.md +14 -13
  21. package/vendor/skills/harvest-asset/SKILL.md +1 -1
  22. package/vendor/templates/project-default/.claude/skills/README.md +1 -1
  23. package/vendor/templates/project-default/.opencode/config.json +16 -0
  24. package/vendor/templates/project-default/.opencode/hooks/session-start.mjs +184 -0
  25. package/vendor/templates/project-default/.opencode/skills/analyze-footage.yml +16 -0
  26. package/vendor/templates/project-default/.opencode/skills/create-project.yml +15 -0
  27. package/vendor/templates/project-default/.opencode/skills/edit-plan.yml +15 -0
  28. package/vendor/templates/project-default/AGENTS.md +1 -1
  29. package/vendor/templates/project-default/CLAUDE.md +1 -1
  30. package/vendor/templates/project-default/akari.sh +139 -0
  31. /package/vendor/packages/schemas/test/fixtures/asset/valid-library/{telop → overlay}/lower-third-clean/fragment.html +0 -0
  32. /package/vendor/packages/schemas/test/fixtures/asset/valid-library/{telop → overlay}/lower-third-clean/preview.png +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # akari-launcher(`akari` コマンド)
2
2
 
3
- 「UI に依存したくない。Claude Code 単体でも、どんなディレクトリでも始められるように」を
3
+ 「UI に依存したくない。opencode 単体でも、どんなディレクトリでも始められるように」を
4
4
  実現する薄いラッパー CLI。npm パッケージ名は `akari-video`、提供するコマンド名は `akari`
5
5
  (npm の `akari` は別プロダクトが取得済みのため。オーナー裁定 2026-07-21 §8-2)。
6
6
 
@@ -21,12 +21,12 @@ akari
21
21
  │ .akari/connections.json の doctor ブロックを更新・表示する
22
22
  │ (キーの値は一切表示しない)
23
23
 
24
- └─ 4. 最後に claude を exec する
25
- (PATH に claude が無ければ、インストール案内を出して終了する)
24
+ └─ 4. 最後に opencode を exec する
25
+ (PATH に opencode が無ければ、インストール案内を出して終了する)
26
26
  ```
27
27
 
28
- `akari` に渡した引数はそのまま `claude` に転送する(例: `akari --continue` は
29
- `claude --continue` を起動する)。
28
+ `akari` に渡した引数はそのまま `opencode` に転送する(例: `akari --continue` は
29
+ `opencode --continue` を起動する)。
30
30
 
31
31
  ## 3 入口の対応表
32
32
 
@@ -35,23 +35,29 @@ AKARI Video は「同じファイル契約(`.akari/` 配下の JSON)に収
35
35
 
36
36
  | 入口 | 実体 | 発動方法 |
37
37
  |---|---|---|
38
- | ターミナル | この `akari` ランチャー CLI | シェルで `akari` と打つ(`npm i -g akari-video` または `npx akari-video` 相当。器のみ、npm publish は本タスクのスコープ外) |
39
- | セッション内 | プラグインの `/akari` スラッシュコマンド、または発話 | Claude Code セッション内で `/akari` と打つ、または普通に話しかけて `create-project` スキルを発動させる |
38
+ | ターミナル | この `akari` ランチャー CLI | `npm i -g akari-video` で導入し、シェルで `akari`(または `akari --opencode`)と打つ |
39
+ | セッション内 | opencode スキルの自動発見、またはプラグインの `/akari` スラッシュコマンド | opencode セッション内で「新しい動画プロジェクトを作りたい」と発話、または Claude Code セッション内で `/akari` と打つ |
40
40
  | アプリ | 接続ボタン(AKARI Video アプリ) | アプリの「はじめる」画面から接続 → はじめかた選択 |
41
41
 
42
42
  3 つとも最終的に同じもの(`.akari/connections.json` / `.akari/intake.json` /
43
43
  `skills/create-project`)を読み書きするため、どの入口から始めても続きは他の入口から
44
44
  再開できる。
45
45
 
46
- ## インストール(現状の到達点)
46
+ ## インストール
47
47
 
48
- npm publish は本タスクのスコープ外(「器まで」)。現状は次のいずれかで実行できる:
48
+ npm publish 済み(v0.1.0 から・provenance 付き)。npm 版はランチャー + エージェント
49
+ ワークフロー(skills / 雛形 / schemas を vendor 同梱)のみで、ブラウザプレビュー
50
+ (`packages/preview-server`)は含まない。フル構成はリポジトリのインストーラー
51
+ (`install.sh` — リリースタグ固定配布)を使う。実行方法:
49
52
 
50
53
  ```sh
51
- # モノレポ checkout 内から、bin を直接実行する
54
+ # モノレポ checkout 内から、bin を直接実行する(opencode モード)
55
+ node packages/akari-launcher/bin/akari.mjs --opencode
56
+
57
+ # Claude Code モード
52
58
  node packages/akari-launcher/bin/akari.mjs
53
59
 
54
- # 将来 npm publish された場合の想定コマンド(現状は publish していないため未検証)
60
+ # 既定の導入(npm publish 済み)
55
61
  npm i -g akari-video && akari
56
62
  npx akari-video
57
63
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.0",
4
- "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code. AKARI Video を Claude Code 単体・どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → `claude` を起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
3
+ "version": "0.1.2",
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": {
7
7
  "akari": "bin/akari.mjs"
package/src/cli.mjs CHANGED
@@ -4,9 +4,9 @@ import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import { resolveLauncherAssets } from './repo-assets.mjs';
6
6
  import { detectProjectState } from './project-state.mjs';
7
- import { findClaudeExecutable } from './path-lookup.mjs';
7
+ import { findClaudeExecutable, findOpencodeExecutable } from './path-lookup.mjs';
8
8
  import { loadTaskLabels } from './task-labels.mjs';
9
- import { describeIntake, claudeMissingGuidance, describeUpdateCommand, describeVersionStatus, formatUpdateNotice } from './messages.mjs';
9
+ import { describeIntake, claudeMissingGuidance, opencodeMissingGuidance, describeUpdateCommand, describeVersionStatus, formatUpdateNotice } from './messages.mjs';
10
10
  import {
11
11
  checkForUpdateSync,
12
12
  readCacheSync,
@@ -31,9 +31,16 @@ export async function run(args, options = {}) {
31
31
  const scaffold = options.scaffold ?? defaultScaffold;
32
32
  const runDoctor = options.runDoctor ?? defaultRunDoctor;
33
33
  const resolveClaude = options.resolveClaude ?? (() => findClaudeExecutable());
34
+ const resolveOpencode = options.resolveOpencode ?? (() => findOpencodeExecutable());
34
35
  const spawnClaude = options.spawnClaude ?? defaultSpawnClaude;
36
+ const spawnOpencode = options.spawnOpencode ?? defaultSpawnOpencode;
35
37
  const env = options.env ?? process.env;
36
38
  const currentVersion = options.currentVersion ?? readOwnVersion();
39
+
40
+ // --opencode / --claude / --claudecode / --yes フラグを解析
41
+ const useOpencode = args.includes('--opencode');
42
+ const autoConfirm = args.includes('--yes') || args.includes('-y');
43
+ const filteredArgs = args.filter(arg => arg !== '--opencode' && arg !== '--claude' && arg !== '--claudecode' && arg !== '--yes' && arg !== '-y');
37
44
 
38
45
  let state = detectProjectState(projectRoot);
39
46
 
@@ -78,16 +85,40 @@ export async function run(args, options = {}) {
78
85
  }
79
86
  (options.refreshUpdate ?? triggerBackgroundRefresh)({ env });
80
87
 
81
- log('Claude Code を起動します…');
82
- const claudePath = resolveClaude();
83
- if (!claudePath) {
84
- log(claudeMissingGuidance());
85
- return { exitCode: 1, scaffolded: state.scaffolded, claudeLaunched: false };
86
- }
88
+ if (useOpencode) {
89
+ log('opencode を起動します…');
90
+ const opencodePath = resolveOpencode();
91
+ if (!opencodePath) {
92
+ log(opencodeMissingGuidance());
93
+ return { exitCode: 1, scaffolded: state.scaffolded, opencodeLaunched: false };
94
+ }
95
+
96
+ const opencodeArgs = autoConfirm ? ['--auto', ...filteredArgs] : filteredArgs;
97
+ const result = spawnOpencode(opencodePath, opencodeArgs, projectRoot);
98
+ const exitCode = typeof result.status === 'number' ? result.status : (result.error ? 1 : 0);
99
+ return { exitCode, scaffolded: state.scaffolded, opencodeLaunched: true };
100
+ } else {
101
+ const claudePath = resolveClaude();
102
+ if (claudePath) {
103
+ log('Claude Code を起動します…');
104
+ const claudeArgs = autoConfirm ? ['--permission-mode', 'acceptEdits', ...filteredArgs] : filteredArgs;
105
+ const result = spawnClaude(claudePath, claudeArgs, projectRoot);
106
+ const exitCode = typeof result.status === 'number' ? result.status : (result.error ? 1 : 0);
107
+ return { exitCode, scaffolded: state.scaffolded, claudeLaunched: true };
108
+ }
87
109
 
88
- const result = spawnClaude(claudePath, args, projectRoot);
89
- const exitCode = typeof result.status === 'number' ? result.status : (result.error ? 1 : 0);
90
- return { exitCode, scaffolded: state.scaffolded, claudeLaunched: true };
110
+ log('Claude Code が見つかりません。opencode を起動します…');
111
+ const opencodePath = resolveOpencode();
112
+ if (!opencodePath) {
113
+ log(claudeMissingGuidance());
114
+ return { exitCode: 1, scaffolded: state.scaffolded, opencodeLaunched: false };
115
+ }
116
+
117
+ const opencodeArgs = autoConfirm ? ['--auto', ...filteredArgs] : filteredArgs;
118
+ const result = spawnOpencode(opencodePath, opencodeArgs, projectRoot);
119
+ const exitCode = typeof result.status === 'number' ? result.status : (result.error ? 1 : 0);
120
+ return { exitCode, scaffolded: state.scaffolded, opencodeLaunched: true };
121
+ }
91
122
  }
92
123
 
93
124
  async function defaultScaffold(projectRoot, assets) {
@@ -111,6 +142,10 @@ function defaultSpawnClaude(claudePath, args, projectRoot) {
111
142
  return spawnSync(claudePath, args, { stdio: 'inherit', cwd: projectRoot });
112
143
  }
113
144
 
145
+ function defaultSpawnOpencode(opencodePath, args, projectRoot) {
146
+ return spawnSync(opencodePath, args, { stdio: 'inherit', cwd: projectRoot });
147
+ }
148
+
114
149
  /**
115
150
  * `akari update`: 現在版・最新版・リリースノート URL を表示し、更新手順を**案内するだけ**
116
151
  * (自動実行はしない — 契約 §4-1)。`--dismiss` を渡すと、キャッシュに載っている最新版の
package/src/messages.mjs CHANGED
@@ -45,6 +45,14 @@ export function claudeMissingGuidance() {
45
45
  ].join('\n');
46
46
  }
47
47
 
48
+ export function opencodeMissingGuidance() {
49
+ return [
50
+ 'opencode コマンドが見つかりませんでした。',
51
+ 'opencode をインストールしてください: npm install -g opencode',
52
+ 'インストール後、このフォルダーで再度 `akari --opencode` を実行してください。'
53
+ ].join('\n');
54
+ }
55
+
48
56
  /**
49
57
  * `akari` 起動時、claude 起動直前に出す 1 行通知(契約 §4-1)。
50
58
  * `checkForUpdateSync` が返す状態から組み立てる。新版が無ければ null。
@@ -45,3 +45,26 @@ export function findClaudeExecutable(pathEnv = process.env.PATH ?? '', platform
45
45
  }
46
46
  return null;
47
47
  }
48
+
49
+ /**
50
+ * PATH 上に `opencode` 実行ファイルがあるかを探す。`findClaudeExecutable` と同じ規約。
51
+ */
52
+ export function findOpencodeExecutable(pathEnv = process.env.PATH ?? '', platform = process.platform, pathExt = process.env.PATHEXT) {
53
+ const directories = pathEnv.split(path.delimiter).filter(Boolean);
54
+ const candidateNames = platform === 'win32'
55
+ ? resolveWindowsExtensions(pathExt).map((extension) => `opencode${extension.toLowerCase()}`)
56
+ : ['opencode'];
57
+
58
+ for (const directory of directories) {
59
+ for (const name of candidateNames) {
60
+ const candidate = path.join(directory, name);
61
+ try {
62
+ accessSync(candidate, fsConstants.X_OK);
63
+ return candidate;
64
+ } catch {
65
+ // このディレクトリには無い。次を探す。
66
+ }
67
+ }
68
+ }
69
+ return null;
70
+ }
@@ -29,7 +29,7 @@ const FALLBACK_CLAUDE_GUIDANCE = [
29
29
  '- `.akari/sidecars/` は分析結果、`.akari/events/` は作業の節目の記録を置く場所です。',
30
30
  '- 節目の記録は 1 件ずつ新しく追加し、すでにある記録は変更しません。',
31
31
  '- 編集スキルは `.claude/skills/` にあり、`/analyze-footage` などの素の名前で使えます。',
32
- '- Codex など他の AI エージェント用の入り口が `.agents/skills/` と `.codex/skills/` にあります(中身は `.claude/skills/` へのリンク)。',
32
+ '- Codex や Cursor など他の AI エージェント用の入り口が `.agents/skills/`、`.cursor/skills/`、`.codex/skills/` にあります(中身は `.claude/skills/` へのリンク)。',
33
33
  '- `.akari/intake.json` の `status` が `submitted` なら、そこに書かれた `tasks` / `target` / `autonomy` に従って進めます。`autonomy` が `checkpoint`(既定)なら、企画の承認や書き出し前などの要所で必ず人に確認します。`status` が `draft` のときは進め方がまだ決まっていないので、フォームや対話で確定させてから作業を始めます。',
34
34
  '- 利用者へは日本語で、内部の仕組みではなく「変更履歴」「企画メモ」「素材」などの言葉で説明します。',
35
35
  '',
@@ -46,7 +46,7 @@ const FALLBACK_AGENT_GUIDANCE = [
46
46
  'スキルは `/analyze-footage`、`/edit-plan`、`/overlay-authoring`、`/setup-library`、',
47
47
  '`/harvest-asset`、`/bake-3d` の素の名前で使う。手順を直接読む場合は',
48
48
  '`.claude/skills/<スキル名>/SKILL.md` を開く。',
49
- 'Codex 等のハーネスでは `.agents/skills/` / `.codex/skills/`(`.claude/skills/` への',
49
+ 'Codex / Cursor 等のハーネスでは `.agents/skills/` / `.cursor/skills/` / `.codex/skills/`(`.claude/skills/` への',
50
50
  'symlink)から同じスキルが自動発見される。',
51
51
  '',
52
52
  '`.akari/intake.json` の `status` が `submitted` なら `tasks` / `target` / `autonomy` に従って進める。',
@@ -63,7 +63,7 @@ const FALLBACK_SKILLS_GUIDANCE = [
63
63
  '',
64
64
  '6 本の編集スキルはこのフォルダーに実体で入り、素の名前で使えます。',
65
65
  '各手順は `.claude/skills/<スキル名>/SKILL.md` から直接読めます。',
66
- '`.agents/skills/` と `.codex/skills/` は他の AI エージェント用の入り口で、この実体への symlink です。',
66
+ '`.agents/skills/`、`.cursor/skills/`、`.codex/skills/` は他の AI エージェント用の入り口で、この実体への symlink です。',
67
67
  '`AKARI-SKILLS-VERSION` はプロジェクト作成時のスキル内容を示します。',
68
68
  'この案内と各スキルは、運用に合わせて自由に書き換えて構いません。',
69
69
  ''
@@ -88,7 +88,7 @@ const FALLBACK_WORKFLOW = {
88
88
  { path: 'exports', label: '書き出し', kind: 'exports' }
89
89
  ],
90
90
  tree: {
91
- hidden: ['.claude', '.agents', '.codex', '.akari', 'CLAUDE.md', 'AGENTS.md', '.gitignore', '.gitkeep'],
91
+ hidden: ['.claude', '.agents', '.codex', '.cursor', '.akari', 'CLAUDE.md', 'AGENTS.md', '.gitignore', '.gitkeep'],
92
92
  sidecarSuffixes: ['.meta.json', '.decisions.json', '.analysis.json'],
93
93
  developerModePreference: 'akari.developerMode'
94
94
  },
@@ -164,6 +164,22 @@ export async function writeFallbackTemplate(destinationDir) {
164
164
  }
165
165
  }, null, 2) + '\n',
166
166
  '.claude/skills/README.md': FALLBACK_SKILLS_GUIDANCE,
167
+ '.opencode/config.json': JSON.stringify({
168
+ name: "AKARI Video Project",
169
+ version: "1.0.0",
170
+ description: "AKARI Video プロジェクト設定",
171
+ skills: {
172
+ autoDiscover: true,
173
+ path: "./skills"
174
+ },
175
+ hooks: {
176
+ sessionStart: "./hooks/session-start.mjs"
177
+ },
178
+ project: {
179
+ type: "akari-video",
180
+ version: "0.1.0"
181
+ }
182
+ }, null, 2) + '\n',
167
183
  '.akari/workflow.json': JSON.stringify(FALLBACK_WORKFLOW, null, 2) + '\n',
168
184
  '.akari/intake.json': JSON.stringify(FALLBACK_INTAKE, null, 2) + '\n',
169
185
  'assets/.gitkeep': '',
@@ -191,7 +207,7 @@ export async function writeFallbackTemplate(destinationDir) {
191
207
  return { writtenFiles, skippedExisting };
192
208
  }
193
209
 
194
- const SKILL_ADAPTER_DIRECTORIES = ['.agents', '.codex'];
210
+ const SKILL_ADAPTER_DIRECTORIES = ['.agents', '.codex', '.cursor', '.opencode'];
195
211
 
196
212
  function isPermissionDenied(error) {
197
213
  return error && typeof error === 'object' && (error.code === 'EPERM' || error.code === 'EACCES');
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import test from "node:test";
7
+
8
+ import { createProject } from "../src/index.mjs";
9
+
10
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const repoRoot = join(packageRoot, "..", "..");
12
+
13
+ async function withScratchRoot(callback) {
14
+ const root = await mkdtemp(join(tmpdir(), "akari-scaffold-opencode-test-"));
15
+ try {
16
+ return await callback(root);
17
+ } finally {
18
+ await rm(root, { recursive: true, force: true });
19
+ }
20
+ }
21
+
22
+ test("opencode 対応: .opencode/config.json が生成される", async () => {
23
+ await withScratchRoot(async (root) => {
24
+ const templateDir = join(repoRoot, "templates", "project-default");
25
+ const destination = join(root, "project");
26
+
27
+ const report = await createProject(destination, templateDir);
28
+
29
+ const configPath = join(destination, ".opencode", "config.json");
30
+ assert.ok((await stat(configPath)).isFile());
31
+
32
+ const config = JSON.parse(await readFile(configPath, "utf8"));
33
+ assert.equal(config.name, "AKARI Video Project");
34
+ assert.equal(config.project.type, "akari-video");
35
+ assert.ok(config.skills.autoDiscover);
36
+ assert.equal(config.skills.path, "./skills");
37
+ assert.equal(config.hooks.sessionStart, "./hooks/session-start.mjs");
38
+ });
39
+ });
40
+
41
+ test("opencode 対応: .opencode/skills/ にスキル定義ファイルが生成される", async () => {
42
+ await withScratchRoot(async (root) => {
43
+ const templateDir = join(repoRoot, "templates", "project-default");
44
+ const destination = join(root, "project");
45
+
46
+ await createProject(destination, templateDir);
47
+
48
+ const skillsDir = join(destination, ".opencode", "skills");
49
+ assert.ok((await stat(skillsDir)).isDirectory());
50
+
51
+ // スキル定義ファイルが存在することを確認
52
+ const analyzeFootage = join(skillsDir, "analyze-footage.yml");
53
+ const editPlan = join(skillsDir, "edit-plan.yml");
54
+ const createProjectSkill = join(skillsDir, "create-project.yml");
55
+
56
+ assert.ok((await stat(analyzeFootage)).isFile());
57
+ assert.ok((await stat(editPlan)).isFile());
58
+ assert.ok((await stat(createProjectSkill)).isFile());
59
+ });
60
+ });
61
+
62
+ test("opencode 対応: .opencode/hooks/session-start.mjs が生成される", async () => {
63
+ await withScratchRoot(async (root) => {
64
+ const templateDir = join(repoRoot, "templates", "project-default");
65
+ const destination = join(root, "project");
66
+
67
+ await createProject(destination, templateDir);
68
+
69
+ const hookPath = join(destination, ".opencode", "hooks", "session-start.mjs");
70
+ assert.ok((await stat(hookPath)).isFile());
71
+
72
+ const hookContent = await readFile(hookPath, "utf8");
73
+ assert.ok(hookContent.includes("SessionStart"));
74
+ assert.ok(hookContent.includes(".akari"));
75
+ });
76
+ });
77
+
78
+ test("opencode 対応: .opencode/skills/ にアダプタリンクが作成される", async () => {
79
+ await withScratchRoot(async (root) => {
80
+ const templateDir = join(repoRoot, "templates", "project-default");
81
+ const destination = join(root, "project");
82
+ const skillsSourceDir = join(repoRoot, "skills");
83
+
84
+ await createProject(destination, templateDir, { skillsSourceDir });
85
+
86
+ // .opencode/skills/ にスキルディレクトリが存在することを確認
87
+ const opencodeSkillsDir = join(destination, ".opencode", "skills");
88
+ assert.ok((await stat(opencodeSkillsDir)).isDirectory());
89
+
90
+ // スキルディレクトリに至少 1 つのスキルがあることを確認
91
+ const entries = await import("node:fs/promises").then(fs => fs.readdir(opencodeSkillsDir));
92
+ assert.ok(entries.length > 0, ".opencode/skills/ にスキルが至少 1 つ存在すること");
93
+ });
94
+ });
@@ -180,10 +180,12 @@ test("installSkillAdapters: on win32 with symlink EPERM, all adapters degrade to
180
180
 
181
181
  assert.deepEqual(report.created.sort(), [
182
182
  ".agents/skills/analyze-footage",
183
- ".codex/skills/analyze-footage"
183
+ ".codex/skills/analyze-footage",
184
+ ".cursor/skills/analyze-footage",
185
+ ".opencode/skills/analyze-footage"
184
186
  ]);
185
187
  assert.deepEqual(report.skippedExisting, []);
186
- assert.equal(report.degraded.length, 2);
188
+ assert.equal(report.degraded.length, 4);
187
189
  assert.ok(report.degraded.every((entry) => entry.method === "junction"));
188
190
  });
189
191
  });
@@ -197,7 +199,9 @@ test("installSkillAdapters: real fs on the current platform still creates plain
197
199
 
198
200
  assert.deepEqual(report.created.sort(), [
199
201
  ".agents/skills/analyze-footage",
200
- ".codex/skills/analyze-footage"
202
+ ".codex/skills/analyze-footage",
203
+ ".cursor/skills/analyze-footage",
204
+ ".opencode/skills/analyze-footage"
201
205
  ]);
202
206
  assert.deepEqual(report.degraded, []);
203
207
  });
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "category": {
29
29
  "type": "string",
30
- "enum": ["3d", "motion", "telop", "audio", "broll", "font", "thumbnail"]
30
+ "$comment": "2026-07-29 に主題(3d/motion/telop/thumbnail…)から配布物の形へ切り替えた。overlay=時間を持つ HTML 断片 / still=時間を持たない HTML シート / scene3d=glTF+Three.js またはベイクレシピ / audio=音声トラックに載るバイナリ / broll=映像トラックに載る実写バイナリ / font=書体バイナリ。主題は tags に逃がす(カテゴリを増やさない)。",
31
+ "enum": ["overlay", "still", "scene3d", "audio", "broll", "font"]
31
32
  },
32
33
  "title": {
33
34
  "type": "string",
@@ -94,6 +95,16 @@
94
95
  "$comment": "drop-folder タイトル正規化照合の来歴フィールド(2026-07-22 導入)。将来の照合経路追加時は enum を拡張する。",
95
96
  "enum": ["title-normalized"]
96
97
  },
98
+ "version": {
99
+ "$comment": "素材の版(2026-07-30 導入。data-contract-versioning 契約 §3 の宿題を果たすもの)。初版は 1。ツマミの削除・改名・type/unit 変更・範囲の縮小、クラス構造やスロットの変更は破壊的変更として bump する。ラベル・説明・tags・ai_usage の更新では上げない。harness/knob-diff.mjs が bump 漏れを検出する。",
100
+ "type": "integer",
101
+ "minimum": 1
102
+ },
103
+ "min_app_version": {
104
+ "$comment": "この素材が要求する AKARI Video の最低版(任意)。古い版で開いたら推測せず正直に止まるための宣言(versioning 契約 原則 3 と同型)。特定版を要求しない素材では省略する。",
105
+ "type": "string",
106
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
107
+ },
97
108
  "remote": {
98
109
  "type": "boolean",
99
110
  "description": "true の場合、このエントリはカタログ上のみに存在し実体ファイルを持たない。source ブロックが実体の代わりになる(2026-07-14 追記・任意フィールド)。"
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
- const usage = "使い方: node packages/schemas/bin/validate-asset.mjs assets/telop/<id>";
9
+ const usage = "使い方: node packages/schemas/bin/validate-asset.mjs assets/overlay/<id>";
10
10
  const assetArgument = process.argv[2];
11
11
 
12
12
  if (!assetArgument || process.argv.length !== 3) {
@@ -68,9 +68,9 @@ function validateMeta(value) {
68
68
  "license",
69
69
  "price",
70
70
  ];
71
- // source / remote / matched_by は任意フィールド。
72
- // 後方互換のため必須フィールドには加えない。
73
- const optionalFields = ["source", "remote", "matched_by"];
71
+ // source / remote / matched_by / version / min_app_version は任意フィールド。
72
+ // 後方互換のため必須フィールドには加えない(version は 2026-07-30 導入で、既存エントリは未設定)。
73
+ const optionalFields = ["source", "remote", "matched_by", "version", "min_app_version"];
74
74
  const allowedFields = [...requiredFields, ...optionalFields];
75
75
  for (const field of requiredFields) {
76
76
  if (!hasOwn(value, field)) fail(`必須フィールドがありません: ${field}`);
@@ -84,9 +84,10 @@ function validateMeta(value) {
84
84
  fail("id は英小文字・数字の kebab-case である必要があります");
85
85
  }
86
86
 
87
- const categories = new Set(["3d", "motion", "telop", "audio", "broll", "font", "thumbnail"]);
87
+ // 2026-07-29: 主題(3d/motion/telop/thumbnail)から配布物の形へ切り替え。主題は tags に逃がす。
88
+ const categories = new Set(["overlay", "still", "scene3d", "audio", "broll", "font"]);
88
89
  if (typeof value.category !== "string" || !categories.has(value.category)) {
89
- fail("category は 3d / motion / telop / audio / broll / font / thumbnail のいずれかである必要があります");
90
+ fail("category は overlay / still / scene3d / audio / broll / font のいずれかである必要があります");
90
91
  }
91
92
 
92
93
  for (const field of ["title", "description", "when_to_use", "ai_usage", "author"]) {
@@ -103,6 +104,16 @@ function validateMeta(value) {
103
104
  fail("price は null または 0 以上の有限数である必要があります");
104
105
  }
105
106
 
107
+ if (hasOwn(value, "version")) {
108
+ if (!Number.isInteger(value.version) || value.version < 1) {
109
+ fail("version は 1 以上の整数である必要があります");
110
+ }
111
+ }
112
+
113
+ if (hasOwn(value, "min_app_version") && !/^\d+\.\d+\.\d+$/.test(String(value.min_app_version))) {
114
+ fail("min_app_version は x.y.z 形式である必要があります");
115
+ }
116
+
106
117
  const matchedByValues = new Set(["title-normalized"]);
107
118
  if (hasOwn(value, "matched_by") && !matchedByValues.has(value.matched_by)) {
108
119
  fail(`matched_by は ${[...matchedByValues].join(" / ")} のいずれかである必要があります`);
@@ -331,23 +342,23 @@ function validateFiles() {
331
342
  }
332
343
 
333
344
  const category = path.basename(path.dirname(assetDir));
334
- if (["motion", "telop", "thumbnail"].includes(category)) {
345
+ if (["overlay", "still"].includes(category)) {
335
346
  const fragmentPath = path.join(assetDir, "fragment.html");
336
347
  if (!isRegularFile(fragmentPath)) {
337
348
  fail(`${category} 素材には fragment.html が必要です`);
338
349
  }
339
350
  }
340
351
 
341
- if (category === "3d") {
342
- // 3d は fragment.html(経路 A: オーバーレイ)か scene.py(経路 B: ベイクレシピ)のどちらか一方
352
+ if (category === "scene3d") {
353
+ // scene3d は fragment.html(経路 A: オーバーレイ)か scene.py(経路 B: ベイクレシピ)のどちらか一方
343
354
  // (契約: docs/contract-2026-07-14-3d-bake-recipe.md)
344
355
  const hasFragment = isRegularFile(path.join(assetDir, "fragment.html"));
345
356
  const hasScene = isRegularFile(path.join(assetDir, "scene.py"));
346
357
  if (hasFragment === hasScene) {
347
- fail("3d 素材は fragment.html(オーバーレイ)か scene.py(ベイクレシピ)のどちらか一方を実体に持つ必要があります");
358
+ fail("scene3d 素材は fragment.html(オーバーレイ)か scene.py(ベイクレシピ)のどちらか一方を実体に持つ必要があります");
348
359
  }
349
360
  if (hasFragment && !payloadFiles.some((filePath) => /\.(?:glb|gltf)$/i.test(filePath))) {
350
- fail("3d オーバーレイ素材には glTF 実体(.glb または .gltf)が必要です");
361
+ fail("scene3d 素材には glTF 実体(.glb または .gltf)が必要です");
351
362
  }
352
363
  }
353
364
 
File without changes
File without changes
File without changes
@@ -28,7 +28,7 @@
28
28
  "lut": { "type": "string", "minLength": 1, "pattern": "\\S" },
29
29
  "intensity": { "type": "number", "minimum": 0, "maximum": 1 }
30
30
  },
31
- "$comment": "docs/contract-2026-07-22-render-basics.md #4。lut はカタログ参照名(catalog/luts/ 配下)または .cube への相対パス。intensity 省略時 1.0。"
31
+ "$comment": "docs/contract-2026-07-22-render-basics.md #4。lut はプリセット参照名(presets/luts/ 配下)または .cube への相対パス。intensity 省略時 1.0。"
32
32
  },
33
33
  "output": {
34
34
  "type": "object",
@@ -39,7 +39,7 @@
39
39
  "properties": {
40
40
  "aspect": {
41
41
  "enum": ["16:9", "9:16", "1:1"],
42
- "$comment": "既存カタログタグの慣用値(例 catalog/3d/vintage-camera/meta.json の tags[])をそのまま採用。新しい語彙を作らない。"
42
+ "$comment": "既存カタログタグの慣用値(例 catalog/scene3d/vintage-camera/meta.json の tags[])をそのまま採用。新しい語彙を作らない。"
43
43
  },
44
44
  "target_duration_band": {
45
45
  "$ref": "#/$defs/nonEmptyString",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "lower-third-clean",
3
- "category": "telop",
3
+ "category": "overlay",
4
4
  "title": "クリーン・ロワーサード",
5
5
  "description": "名前と肩書を端正な2行で見せる、ダークプレートとアクセントラインのロワーサード。日本語の人物紹介に最適化している。",
6
6
  "when_to_use": "インタビュー、登壇者紹介、採用・企業映像など、話者の名前と肩書を落ち着いたトーンで提示するシーン",
@@ -15,7 +15,7 @@ function run(fixture) {
15
15
  }
16
16
 
17
17
  test("library meta with title-normalized matched_by passes", () => {
18
- const executed = run("valid-library/telop/lower-third-clean");
18
+ const executed = run("valid-library/overlay/lower-third-clean");
19
19
  assert.equal(executed.status, 0, executed.stderr);
20
20
  assert.match(executed.stdout, /^OK: /);
21
21
  assert.equal(executed.stderr.trim(), "");
@@ -33,7 +33,7 @@ test -x "$BLENDER" && "$BLENDER" --version | head -1
33
33
 
34
34
  # 2. scene.py の authoring 契約
35
35
 
36
- 参照実装: [`assets/3d/vintage-camera-turntable/scene.py`](../../assets/3d/vintage-camera-turntable/scene.py)
36
+ 参照実装: [`assets/scene3d/vintage-camera-turntable/scene.py`](../../assets/scene3d/vintage-camera-turntable/scene.py)
37
37
 
38
38
  - **自己完結**: レシピディレクトリ単体をプロジェクトへコピーしても動くこと。リポ内の他ファイルを import しない。アセットは `os.path.dirname(os.path.abspath(__file__))` 相対で読む
39
39
  - **引数契約**(`--` 以降を argparse で受ける):
@@ -60,13 +60,13 @@ test -x "$BLENDER" && "$BLENDER" --version | head -1
60
60
 
61
61
  1. `ffprobe` で解像度・fps・フレーム数が指定と一致することを確認する
62
62
  2. 先頭・中間・末尾のフレームを `ffmpeg -ss <t> -frames:v 1` で抽出して目視する(構図・ライティング・アニメーションの向き)
63
- 3. ライブラリ入庫時は中間フレームから `preview.png` を作り、`node packages/schemas/bin/validate-asset.mjs assets/3d/<id>` を通す
63
+ 3. ライブラリ入庫時は中間フレームから `preview.png` を作り、`node packages/schemas/bin/validate-asset.mjs assets/scene3d/<id>` を通す
64
64
 
65
65
  # 5. ライブラリ入庫 / プロジェクト採用
66
66
 
67
67
  - 入庫基準は素材ライブラリ契約と同じ: シーン構築・ライティング・カメラワークの設計コストが高いレシピだけを入れる
68
68
  - meta.json は knobs を `param` で宣言し、`requires: ["blender"]`。provenance にアセットの取得元・ライセンス・梱包手順を書く
69
- - プロジェクト採用 = レシピ一式を `<project>/assets/3d/<id>/` へ複製 → params 上書き → ベイク → mp4 を edit.json にクリップ配置。provenance にレシピ id / 版 / params / Blender バージョンを記録する
69
+ - プロジェクト採用 = レシピ一式を `<project>/assets/scene3d/<id>/` へ複製 → params 上書き → ベイク → mp4 を edit.json にクリップ配置。provenance にレシピ id / 版 / params / Blender バージョンを記録する
70
70
 
71
71
  # よくある間違い
72
72
 
@@ -41,6 +41,26 @@ node skills/create-project/bin/create-project.mjs <target-dir> [--template <path
41
41
  - `<target-dir>`: 作成先ディレクトリ。新規でも既存の非空フォルダでもよい。既存ファイルは上書きしない。
42
42
  - `--template <path>`: 雛形ディレクトリを明示指定する。省略時はリポ checkout の `templates/project-default/` を使う。それ以外の解決手段はない。
43
43
 
44
+ ## 作例から始めたいと言われたとき
45
+
46
+ 「解説ショートを作りたい」「あの縦型の解説動画みたいなやつ」のように**完成形の作例から
47
+ 始めたい**依頼は、雛形解決の 2 択(ハードルール 1)とは別の話である。本スキルは**器**を作る
48
+ ものであり、作例は器ではない。
49
+
50
+ - 解説ショート(3 面構成の縦型・VOICEVOX ナレーション駆動・図解カードの段階表示):
51
+ [`templates/kaisetsu-short/`](../../templates/kaisetsu-short/README.ja.md)
52
+ - 使い方はディレクトリごと複製し、`sample-project/` を自分の台本 JSON に置き換える。
53
+ まず動作確認するなら同梱サンプル(音声同梱のため VOICEVOX 不要):
54
+
55
+ ```sh
56
+ cd templates/kaisetsu-short && node tools/build.mjs sample-project --no-synthesize
57
+ ```
58
+
59
+ - **作例を `--template` に渡さない。** `.akari/` などの器の構造を持たないため、
60
+ 雛形として解決すると不完全なプロジェクトになる。器が要るなら通常どおり
61
+ `templates/project-default/` で作成し、そのうえで作例を複製して中身を持ち込む
62
+ - テンプレート 2 種(器 / 作例)の違いは [`templates/INDEX.md`](../../templates/INDEX.md) を参照
63
+
44
64
  ## intake.json(進め方フォームの保存先)
45
65
 
46
66
  作成直後の `.akari/intake.json` は `status: "draft"`・空 `tasks` から始まる(契約: `packages/schemas/intake.schema.json`)。プロジェクトの CLAUDE.md には intake の規律を追記する — `status: "submitted"` なら `tasks` / `target` / `autonomy` に従い、`autonomy: "checkpoint"`(既定)なら企画承認・書き出し前などの要所で人に確認し、`status: "draft"` のままなら進め方をフォームまたは対話で確定させてから進める。
@@ -102,9 +102,10 @@
102
102
  コミットしない。**
103
103
  - `catalog/` は `remote: true` の**取得先索引であり実体を持たない**([catalog/INDEX.md](../../catalog/INDEX.md))。
104
104
  未取得なら「取得が要る」ことを素材計画に明記する。
105
- - 例外: `catalog/telop/` だけは本リポへベンダリングされた目次方式カタログで、`meta.json`
106
- 持たない(実測: `index.jsonl` に license フィールドは無い)。ライセンス根拠は
107
- [catalog/telop/INDEX.md](../../catalog/telop/INDEX.md) の来歴で確認し、実際にレンダリングへ
105
+ - `presets/telop/` は素材カタログではなく、bake CLI が id で引く参照表である(本リポへ
106
+ ベンダリングされた目次方式・`meta.json` を持たない。実測: `index.jsonl` に license
107
+ フィールドは無い)。ライセンス根拠は
108
+ [presets/telop/INDEX.md](../../presets/telop/INDEX.md) の来歴で確認し、実際にレンダリングへ
108
109
  使う書体のライセンスは `catalog/font/<id>/meta.json` で別途確認する。
109
110
  - 該当ヒットが無いことを「あれば提案」と記録しない。[report-guide.md](report-guide.md#素材計画) の
110
111
  三択(あれば提案 / なければ生成 / 使わない)へ落とし、プレビュー・検索結果を捏造しない。
@@ -157,23 +158,23 @@
157
158
 
158
159
  | scene | 表の行 | 第一候補 | フィルタ判定 | 採用手段 | 採用素材 |
159
160
  |---|---|---|---|---|---|
160
- | `sc-01` | 製品・物体の説明 | 3D モデル | `3D` は許可 → 通過 | 3D | `catalog/3d/modern-smartphone` |
161
+ | `sc-01` | 製品・物体の説明 | 3D モデル | `3D` は許可 → 通過 | 3D | `catalog/scene3d/modern-smartphone` |
161
162
  | `sc-02` | データ・数値・比較 | HTML グラフ/表 | `HTML 図解` は許可 → 通過 | HTML 図解 | 生成(overlay HTML)+ 書体 `catalog/font/noto-sans-jp` |
162
- | `sc-03` | 感情・主張の瞬間 | 文字演出(語レベル) | `文字演出` は許可 → 通過 | 文字演出 | `catalog/telop/ref3_mincho_flash` |
163
+ | `sc-03` | 感情・主張の瞬間 | 文字演出(語レベル) | `文字演出` は許可 → 通過 | 文字演出 | `presets/telop/ref3_mincho_flash` |
163
164
 
164
165
  素材の選定根拠(カタログ実測):
165
166
 
166
- - `sc-01`: `catalog/3d/` の 3 件を `when_to_use` で突き合わせ、`modern-smartphone` の
167
+ - `sc-01`: `catalog/scene3d/` の 3 件を `when_to_use` で突き合わせ、`modern-smartphone` の
167
168
  「アプリ紹介・UI 解説・プロダクトデモで、実機に画面を映し込んだモックアップ映像を作るとき」が
168
169
  シーンの意味と一致する(`vintage-camera` はレトロ小物、`studio-hdri` は環境光であり不一致)。
169
170
  `license.spdx` = `CC0-1.0` / `attribution_required: false` → クレジット不要。`remote: true` の
170
171
  ため取得が要ることを素材計画に明記する。
171
172
  - `sc-02`: `catalog/` にグラフ・表のカテゴリは存在しない(実測のディレクトリは
172
- 3d / audio / broll / font / luts / telop)。
173
+ scene3d / audio / avatars / broll / font)。
173
174
  三択の「なければ生成」として overlay HTML を自作し、書体は `catalog/font/noto-sans-jp`
174
175
  (`OFL-1.1` / クレジット不要)を使う。**「使わない」列の 3D の飾りは、`3D` が許可されていても
175
176
  置かない。**
176
- - `sc-03`: `catalog/telop/index.jsonl` を `use_when.beats ⊇ emotion` で検索するとヒットは 3 件
177
+ - `sc-03`: `presets/telop/index.jsonl` を `use_when.beats ⊇ emotion` で検索するとヒットは 3 件
177
178
  (`ref3_karaoke_flash` = エモい歌モノ / `ref3_kid_karaoke` = 子ども向け / `ref3_tl_r3s7_07` =
178
179
  昭和ラジオ風)で、いずれも解説トーン(真面目)と不一致だった。**同じ「文字演出」の範囲内で**
179
180
  `tone` 一致を優先し、`roles: emphasis` かつ `tone: 真面目・エモい` の `ref3_mincho_flash`
@@ -185,7 +186,7 @@
185
186
  ```text
186
187
  sc-01 @ 24.0 | 意味: 製品・物体の説明 | 行: 製品・物体の説明 / 第一候補 | 手段: 3D | 素材: modern-smartphone(catalog / CC0-1.0 / クレジット不要) | 理由: when_to_use「実機に画面を映し込んだモックアップ」がシーンの意味に一致
187
188
  sc-02 @ 98.0 | 意味: データ・数値・比較 | 行: データ・数値・比較 / 第一候補 | 手段: HTML 図解 | 素材: overlays/sc-02-chart.html(生成 / 書体 noto-sans-jp OFL-1.1 / クレジット不要) | 理由: catalog にグラフ素材のカテゴリが無く三択の「なければ生成」。3D の飾りは禁止列のため不使用
188
- sc-03 @ 232.0 | 意味: 感情・主張の瞬間 | 行: 感情・主張の瞬間 / 第一候補 | 手段: 文字演出 | 素材: ref3_mincho_flash(catalog/telop / 来歴 akari-telop / クレジット不要) | 理由: emotion 一致の 3 件はトーン不一致のため、同じ文字演出の中で tone 一致(真面目・エモい)の emphasis を採用
189
+ sc-03 @ 232.0 | 意味: 感情・主張の瞬間 | 行: 感情・主張の瞬間 / 第一候補 | 手段: 文字演出 | 素材: ref3_mincho_flash(presets/telop / 来歴 akari-telop / クレジット不要) | 理由: emotion 一致の 3 件はトーン不一致のため、同じ文字演出の中で tone 一致(真面目・エモい)の emphasis を採用
189
190
  ```
190
191
 
191
192
  ### ケース 2 — `3D` と `AI 生成` が不許可(5 値)
@@ -198,7 +199,7 @@ sc-03 @ 232.0 | 意味: 感情・主張の瞬間 | 行: 感情・主張の瞬間
198
199
  |---|---|---|---|---|---|
199
200
  | `sc-01` | 製品・物体の説明 | 3D モデル | `3D` が**除外** → 第二候補へ | 実写 B ロール | `catalog/broll/laptop-typing-closeup` |
200
201
  | `sc-02` | データ・数値・比較 | HTML グラフ/表 | `HTML 図解` は許可 → 通過 | HTML 図解 | 生成(overlay HTML)+ 書体 `catalog/font/noto-sans-jp` |
201
- | `sc-03` | 感情・主張の瞬間 | 文字演出(語レベル) | `文字演出` は許可 → 通過 | 文字演出 | `catalog/telop/ref3_mincho_flash` |
202
+ | `sc-03` | 感情・主張の瞬間 | 文字演出(語レベル) | `文字演出` は許可 → 通過 | 文字演出 | `presets/telop/ref3_mincho_flash` |
202
203
 
203
204
  ケース 1 との差分と、`AI 生成` 不許可が効いた箇所:
204
205
 
@@ -248,9 +249,9 @@ sc-01 @ 24.0 | 意味: 製品・物体の説明 | 行: 製品・物体の説明
248
249
  worked example が引いたカタログの実データは次で確認した(`catalog/` は読み取りのみ)。
249
250
 
250
251
  ```
251
- $ ls catalog/3d | tr '\n' ' '
252
+ $ ls catalog/scene3d | tr '\n' ' '
252
253
  INDEX.md modern-smartphone studio-hdri vintage-camera
253
- $ node -e "const m=require('./catalog/3d/modern-smartphone/meta.json');console.log(m.license.spdx,m.license.attribution_required,m.remote)"
254
+ $ node -e "const m=require('./catalog/scene3d/modern-smartphone/meta.json');console.log(m.license.spdx,m.license.attribution_required,m.remote)"
254
255
  CC0-1.0 false true
255
256
  $ node -e "const m=require('./catalog/broll/laptop-typing-closeup/meta.json');console.log(m.license.spdx,m.license.attribution_required)"
256
257
  LicenseRef-Mixkit-Free-License false
@@ -258,7 +259,7 @@ $ node -e "const m=require('./catalog/font/noto-sans-jp/meta.json');console.log(
258
259
  OFL-1.1 false
259
260
  ```
260
261
 
261
- `catalog/telop/index.jsonl`(36 件)を `use_when.beats ⊇ emotion` で絞ると 3 件
262
+ `presets/telop/index.jsonl`(36 件)を `use_when.beats ⊇ emotion` で絞ると 3 件
262
263
  (`ref3_karaoke_flash` / `ref3_kid_karaoke` / `ref3_tl_r3s7_07`)であり、採用した
263
264
  `ref3_mincho_flash` は `roles: ["emphasis"]` / `tone: ["真面目","エモい"]` / `strength: high` である。
264
265
 
@@ -46,7 +46,7 @@ source fragment と同階層の依存 asset を読み、作業用一時ディレ
46
46
  ### その他の自動導出
47
47
 
48
48
  - `id`: source 名と内容から kebab-case で生成する。
49
- - `category`: `3d` / `motion` / `telop` / `audio` / `broll` / `font` / `thumbnail` から単一カテゴリを内容から選ぶ。横断軸は `tags` にする。サムネイルの HTML 文字組テンプレは `thumbnail`(背景差し替え前提で、文字組レイヤーだけを素材化する)。
49
+ - `category`: `overlay` / `still` / `scene3d` / `audio` / `broll` / `font` から単一カテゴリを**配布物の形**で選ぶ(2026-07-29 に主題軸から変更)。時間を持つ HTML 断片は `overlay`、時間を持たず画像に焼く HTML シート(サムネ構図等)は `still`、Three.js + glTF またはベイクレシピは `scene3d`。**主題(テロップ・黒板・ロワーサード・BGM・SFX・サムネ等)はカテゴリにせず `tags` に逃がす。カテゴリを増やさない。**
50
50
  - `title` / `description` / `tags`: 見た目、役割、aspect、scene を source と利用文脈から要約する。
51
51
  - `provenance`: origin project、元 path、生成手、prompt、日時を既存情報から埋める。分からない値を捏造しない。
52
52
  - media の寸法、duration、codec、model 情報は `sips`、`ffprobe`、利用可能な GLB inspector などで読める場合だけ確認し、description / tags / preview 判断へ使う。schema にないトップレベル field は追加しない。
@@ -17,7 +17,7 @@ AKARI Video の編集スキルは、このフォルダーに実体で入って
17
17
  分析結果と節目の記録の詳しい約束は
18
18
  `.claude/skills/analyze-footage/references/akari-data-contract.md` を参照してください。
19
19
 
20
- `.agents/skills/` と `.codex/skills/` は Codex など他の AI エージェント用の入り口で、
20
+ `.agents/skills/`、`.cursor/skills/`、`.codex/skills/` は Codex / Cursor など他の AI エージェント用の入り口で、
21
21
  このフォルダーの実体への symlink です。
22
22
 
23
23
  `AKARI-SKILLS-VERSION` は、このプロジェクトを作ったときのスキル内容を示す記録です。
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "AKARI Video Project",
3
+ "version": "1.0.0",
4
+ "description": "AKARI Video プロジェクト設定",
5
+ "skills": {
6
+ "autoDiscover": true,
7
+ "path": "./skills"
8
+ },
9
+ "hooks": {
10
+ "sessionStart": "./hooks/session-start.mjs"
11
+ },
12
+ "project": {
13
+ "type": "akari-video",
14
+ "version": "0.1.0"
15
+ }
16
+ }
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook for opencode: カレントに .akari/ プロジェクトがあれば「続きから」体験として
3
+ // 進捗(intake 状態・直近イベント)+ 次の一手をコンテキストへ注入する。無ければ
4
+ // 何もしない。
5
+
6
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const FALLBACK_TASK_LABELS = {
11
+ 'transcribe-captions': '文字起こし・テロップ',
12
+ 'silence-cut': 'いらない間・NG のカット',
13
+ 'bgm-sfx': 'BGM・効果音',
14
+ narration: 'ナレーション(自分の声 / 既製の声)',
15
+ '3d-inserts': '3D・画面はめ込みの演出'
16
+ };
17
+
18
+ const AUTONOMY_LABELS = {
19
+ 'full-auto': 'すべておまかせ',
20
+ checkpoint: '要所で確認(既定)',
21
+ collaborative: '相談しながら'
22
+ };
23
+
24
+ async function main() {
25
+ const stdin = await readStdin();
26
+ let hookInput = {};
27
+ try {
28
+ hookInput = JSON.parse(stdin || '{}');
29
+ } catch {
30
+ hookInput = {};
31
+ }
32
+
33
+ const cwd = typeof hookInput.cwd === 'string' && hookInput.cwd
34
+ ? hookInput.cwd
35
+ : process.cwd();
36
+
37
+ const akariDir = path.join(cwd, '.akari');
38
+ if (!existsSync(akariDir)) {
39
+ return;
40
+ }
41
+
42
+ const context = buildContext(cwd, akariDir);
43
+ if (!context) return;
44
+
45
+ // opencode のフック形式に合わせて出力
46
+ process.stdout.write(JSON.stringify({
47
+ hookEventName: 'SessionStart',
48
+ additionalContext: context
49
+ }));
50
+ }
51
+
52
+ function buildContext(cwd, akariDir) {
53
+ const lines = ['AKARI Video プロジェクトの続きから。'];
54
+
55
+ const intake = readJsonSafe(path.join(akariDir, 'intake.json'));
56
+ lines.push(summarizeIntake(intake, cwd));
57
+
58
+ const latestEvent = readLatestEvent(path.join(akariDir, 'events'));
59
+ lines.push(latestEvent ? nudgeFor(latestEvent) : 'まだ記録された節目はありません。');
60
+
61
+ return lines.join('\n');
62
+ }
63
+
64
+ function summarizeIntake(intake) {
65
+ if (!intake) {
66
+ return '進め方フォーム(.akari/intake.json)がまだありません。';
67
+ }
68
+ if (intake.status !== 'submitted') {
69
+ return '進め方はまだ未確定です(.akari/intake.json: draft)。フォーム・対話で「やること・尺・おまかせ度」を確定してください。';
70
+ }
71
+ const labels = loadTaskLabels();
72
+ const tasks = Array.isArray(intake.tasks) ? intake.tasks : [];
73
+ const taskText = tasks.length > 0 ? tasks.map((id) => labels[id] ?? id).join('、') : '(やること未選択)';
74
+ const autonomyText = AUTONOMY_LABELS[intake.autonomy] ?? intake.autonomy ?? '未設定';
75
+ const target = intake.target ?? {};
76
+ const targetText = target.keep_length
77
+ ? '尺は素材のまま'
78
+ : typeof target.duration_s === 'number'
79
+ ? `目標尺 ${target.duration_s} 秒`
80
+ : '尺は未指定';
81
+ return `進め方: ${taskText} / ${targetText} / 進め方は${autonomyText}。checkpoint なら企画承認・書き出し前などの要所で人に確認する。`;
82
+ }
83
+
84
+ function loadTaskLabels() {
85
+ try {
86
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
87
+ const repoRoot = path.resolve(scriptDir, '..', '..', '..');
88
+ const schemaPath = path.join(repoRoot, 'packages', 'schemas', 'intake.schema.json');
89
+ const schema = readJsonSafe(schemaPath);
90
+ const labels = schema?.['x-akari-labels'];
91
+ if (labels && typeof labels === 'object' && !Array.isArray(labels)) {
92
+ return labels;
93
+ }
94
+ } catch {
95
+ // フォールバックへ。
96
+ }
97
+ return FALLBACK_TASK_LABELS;
98
+ }
99
+
100
+ function readLatestEvent(eventsDir) {
101
+ if (!existsSync(eventsDir)) return null;
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(eventsDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
105
+ } catch {
106
+ return null;
107
+ }
108
+
109
+ let latest = null;
110
+ let latestTime = -Infinity;
111
+ for (const entry of entries) {
112
+ const parsed = readJsonSafe(path.join(eventsDir, entry.name));
113
+ if (!parsed || typeof parsed.type !== 'string') continue;
114
+ const timestamp = parsed.occurredAt ?? parsed.at;
115
+ const time = typeof timestamp === 'string' ? Date.parse(timestamp) : NaN;
116
+ if (Number.isNaN(time)) continue;
117
+ if (time > latestTime) {
118
+ latestTime = time;
119
+ latest = parsed;
120
+ }
121
+ }
122
+ return latest;
123
+ }
124
+
125
+ function nudgeFor(event) {
126
+ const subject = sanitize(event.asset ?? event.artifact ?? event.path);
127
+ switch (event.type) {
128
+ case 'video-added':
129
+ return subject
130
+ ? `${subject} が追加されました。analyze-footage スキルで分析を開始してください。`
131
+ : '新しい素材が追加されました。analyze-footage スキルで分析を開始してください。';
132
+ case 'report-generated':
133
+ return subject
134
+ ? `レポートを作成しました(${subject})。内容を確認し、問題なければ承認してください。`
135
+ : 'レポートを作成しました。内容を確認し、問題なければ承認してください。';
136
+ case 'report-approved':
137
+ return 'レポートが承認されました。edit-plan スキルで編集を進めてください。';
138
+ case 'edit-completed':
139
+ return '編集が完了しました。プレビューで仕上がりを確認し、必要ならテロップ等を overlay-authoring スキルで調整してください。';
140
+ case 'export-completed':
141
+ return subject
142
+ ? `書き出しが完了しました(${subject})。exports フォルダーで最終版を確認してください。`
143
+ : '書き出しが完了しました。exports フォルダーで最終版を確認してください。';
144
+ default:
145
+ return subject
146
+ ? `${subject} が更新されました。内容を確認し、次の一手を進めてください。`
147
+ : `更新がありました(${event.type})。内容を確認し、次の一手を進めてください。`;
148
+ }
149
+ }
150
+
151
+ function sanitize(value) {
152
+ const trimmed = typeof value === 'string' ? value.replace(/[\r\n]+/g, ' ').trim() : '';
153
+ return trimmed || undefined;
154
+ }
155
+
156
+ function readJsonSafe(filePath) {
157
+ try {
158
+ return JSON.parse(readFileSync(filePath, 'utf8'));
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function readStdin() {
165
+ return new Promise((resolve) => {
166
+ if (process.stdin.isTTY) {
167
+ resolve('');
168
+ return;
169
+ }
170
+ let data = '';
171
+ process.stdin.setEncoding('utf8');
172
+ process.stdin.on('data', (chunk) => { data += chunk; });
173
+ process.stdin.on('end', () => resolve(data));
174
+ process.stdin.on('error', () => resolve(data));
175
+ });
176
+ }
177
+
178
+ main()
179
+ .catch(() => {
180
+ // HOOK SAFETY: 何が起きてもセッションを壊さない。
181
+ })
182
+ .finally(() => {
183
+ process.exitCode = 0;
184
+ });
@@ -0,0 +1,16 @@
1
+ name: analyze-footage
2
+ description: 動画素材 1 本から 720p プロキシ、ローカル既定の文字起こし(Mac は macOS SpeechAnalyzer / 共通は whisper.cpp・クラウドは承認制)、視認済みキーフレーム、編集イベント、人物関連トラックを作り、analysis.json v0 にまとめるスキル。新しい撮影素材を取り込むとき、素材単体の編集前分析を頼まれたとき、または edit-plan の前処理として素材ごとの分析が必要なときに使う。
3
+ triggers:
4
+ - 素材を分析して
5
+ - analyze footage
6
+ - 動画を分析
7
+ - プロキシ作成
8
+ - 文字起こし
9
+ tools:
10
+ - bash
11
+ - read
12
+ - write
13
+ - edit
14
+ - grep
15
+ - glob
16
+ skillPath: ../../skills/analyze-footage/SKILL.md
@@ -0,0 +1,15 @@
1
+ name: create-project
2
+ description: AKARI Video の新規プロジェクトを headless で作成する。`templates/project-default/` を再帰コピーし、雛形バージョンを記録し、安全な場合のみ git 初期化して、作成結果レポート HTML を生成する。アプリ起動は不要。新しい動画プロジェクトを作るとき、または既存フォルダを AKARI Video プロジェクトとして補完するときに使う。
3
+ triggers:
4
+ - 新しいプロジェクトを作りたい
5
+ - create project
6
+ - プロジェクト作成
7
+ - 動画プロジェクト
8
+ tools:
9
+ - bash
10
+ - read
11
+ - write
12
+ - edit
13
+ - grep
14
+ - glob
15
+ skillPath: ../../skills/create-project/SKILL.md
@@ -0,0 +1,15 @@
1
+ name: edit-plan
2
+ description: analyze-project が作る分析レポート(interpretation.json + analysis-report.html)を一次証拠として読み、方針・素材計画・実行をチャットの明示承認で確定したうえで edit.json v0 とオーバーレイ HTML へ落とすスキル。複数素材の編集計画、素材ゼロからの生成計画(質問対話 → plan.json の仮枠タイムライン確定)、分析結果からカットや BGM・SFX・B ロールを決める依頼で使う。
3
+ triggers:
4
+ - 編集方針を立てて
5
+ - edit plan
6
+ - 編集計画
7
+ - カット構成
8
+ tools:
9
+ - bash
10
+ - read
11
+ - write
12
+ - edit
13
+ - grep
14
+ - glob
15
+ skillPath: ../../skills/edit-plan/SKILL.md
@@ -28,7 +28,7 @@
28
28
  - `/harvest-asset` … 素材の収集
29
29
  - `/bake-3d` … 3D 素材の焼き込み
30
30
 
31
- Codex 等のハーネスでは `.agents/skills/` / `.codex/skills/`(`.claude/skills/` への symlink)から
31
+ Codex / Cursor 等のハーネスでは `.agents/skills/` / `.cursor/skills/` / `.codex/skills/`(`.claude/skills/` への symlink)から
32
32
  同じスキルが自動発見される。
33
33
 
34
34
  スキルを自動で読まない作業環境では、次のプロジェクト内相対パスから手順を直接読む。
@@ -21,7 +21,7 @@
21
21
 
22
22
  編集スキルは `.claude/skills/` に入っています。`/analyze-footage`、`/edit-plan`、
23
23
  `/overlay-authoring`、`/setup-library`、`/harvest-asset`、`/bake-3d` の素の名前で使えます。
24
- Codex など他の AI エージェント用の入り口が `.agents/skills/` と `.codex/skills/` にあります
24
+ Codex や Cursor など他の AI エージェント用の入り口が `.agents/skills/`、`.cursor/skills/`、`.codex/skills/` にあります
25
25
  (中身は `.claude/skills/` へのリンクです)。
26
26
  詳しい進め方と、スキル文書を直接読む場合の場所は `AGENTS.md` を参照してください。
27
27
 
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # AKARI Video — メインエントリーポイント
3
+ set -euo pipefail
4
+
5
+ MUTED='\033[0;2m'; RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[38;5;214m'; BOLD='\033[1m'; NC='\033[0m'
6
+ info() { echo -e "${GREEN}$1${NC}"; }
7
+ warn() { echo -e "${YELLOW}$1${NC}"; }
8
+ err() { echo -e "${RED}$1${NC}"; }
9
+
10
+ # ─── Resolve script location (works via PATH/symlink) ───
11
+ SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
12
+
13
+ # ─── Find monorepo root ───
14
+ find_monorepo() {
15
+ local dir; dir="$(cd "$(dirname "$1")" && pwd)"
16
+ while [[ "$dir" != "/" ]]; do
17
+ if [[ -f "$dir/packages/akari-launcher/bin/akari.mjs" ]]; then echo "$dir"; return 0; fi
18
+ dir="$(dirname "$dir")"
19
+ done
20
+ if [[ -n "${AKARI_MONOREPO:-}" ]] && [[ -f "$AKARI_MONOREPO/packages/akari-launcher/bin/akari.mjs" ]]; then
21
+ echo "$AKARI_MONOREPO"; return 0
22
+ fi
23
+ return 1
24
+ }
25
+ MONOREPO="$(find_monorepo "$SCRIPT_PATH")" || { err "Cannot find AKARI Video monorepo. Set AKARI_MONOREPO or run from within the repo."; exit 1; }
26
+
27
+ # ─── Preview server launcher ───
28
+ cmd_preview() {
29
+ local PROJECT="${AKARI_PROJECT:-.}"
30
+ local PORT="${AKARI_PORT:-4567}"
31
+ local OPEN_BROWSER=false
32
+ local ARGS=("$@")
33
+
34
+ while [[ $# -gt 0 ]]; do
35
+ case "$1" in
36
+ -h|--help|-\?)
37
+ echo "Usage: akari.sh --preview [options] [project-path] [port]"
38
+ echo ""
39
+ echo "Options:"
40
+ echo " -p, --port <port> Port number (default: 4567, env: AKARI_PORT)"
41
+ echo " -o, --open Open browser automatically"
42
+ echo ""
43
+ echo "Port can also be given as a bare number (the last positional arg)."
44
+ echo "If the project dir has no edit.json, it is auto-created."
45
+ echo ""
46
+ echo "Examples:"
47
+ echo " akari.sh --preview # current dir, port 4567"
48
+ echo " akari.sh --preview 3000 # current dir, port 3000"
49
+ echo " akari.sh --preview ~/my-video 3000"
50
+ return 0 ;;
51
+ -p|--port) PORT="$2"; shift 2 ;;
52
+ -o|--open) OPEN_BROWSER=true; shift ;;
53
+ *)
54
+ if [[ "$1" =~ ^[0-9]+$ ]]; then PORT="$1"
55
+ elif [[ "$PROJECT" == "." ]] || [[ "$PROJECT" == "$AKARI_PROJECT" ]]; then PROJECT="$1"
56
+ else err "Multiple project paths specified"; return 1; fi
57
+ shift ;;
58
+ esac
59
+ done
60
+
61
+ mkdir -p "$PROJECT" 2>/dev/null || true
62
+ PROJECT="$(cd "$PROJECT" 2>/dev/null && pwd)" || { err "Project directory not found: $PROJECT"; return 1; }
63
+
64
+ if [[ ! -f "$PROJECT/edit.json" ]]; then
65
+ info "Project not initialized. Scaffolding from template..."
66
+ cp -r "$MONOREPO/templates/project-default/"* "$PROJECT/" 2>/dev/null || true
67
+ cp "$MONOREPO/templates/project-default/.gitignore" "$PROJECT/" 2>/dev/null || true
68
+ cp -r "$MONOREPO/templates/project-default/".* "$PROJECT/" 2>/dev/null || true
69
+ touch "$PROJECT/assets/.gitkeep" "$PROJECT/exports/.gitkeep" "$PROJECT/planning/.gitkeep" 2>/dev/null || true
70
+ mkdir -p "$PROJECT/.akari/cache" "$PROJECT/.akari/diffs" "$PROJECT/.akari/events" "$PROJECT/.akari/reports" "$PROJECT/.akari/sidecars" "$PROJECT/.akari/work" 2>/dev/null || true
71
+ echo '{"version":1,"status":"draft"}' > "$PROJECT/.akari/intake.json" 2>/dev/null || true
72
+ echo "{}" > "$PROJECT/edit.json" 2>/dev/null || true
73
+ info " Created: $PROJECT"
74
+ fi
75
+
76
+ info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
77
+ info " AKARI Video Preview Server"
78
+ info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
79
+ echo ""
80
+ echo -e " ${BOLD}Project:${NC} $PROJECT"
81
+ echo -e " ${BOLD}URL:${NC} http://localhost:$PORT"
82
+ echo ""
83
+ echo -e " ${MUTED}Ctrl+C で停止${NC}"
84
+ echo ""
85
+
86
+ node "$MONOREPO/packages/preview-server/src/server.mjs" "$PROJECT" --port "$PORT" &
87
+ PID=$!
88
+ trap "kill $PID 2>/dev/null; exit" INT TERM
89
+
90
+ if [[ "$OPEN_BROWSER" == "true" ]]; then
91
+ sleep 1
92
+ case "$(uname -s)" in Darwin*) open "http://localhost:$PORT" ;; Linux*) xdg-open "http://localhost:$PORT" 2>/dev/null || true ;; esac
93
+ fi
94
+ wait $PID
95
+ }
96
+
97
+ # ─── Main ───
98
+ if [[ $# -eq 0 ]]; then
99
+ # デフォルトは Claude Code(launcher の自動検出に任せる)
100
+ # --opencode を付けたい場合は明示的に指定
101
+ exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs"
102
+ fi
103
+
104
+ case "$1" in
105
+ -h|--help|-\?)
106
+ SCRIPT_NAME="$(basename "$0")"
107
+ echo "AKARI Video — AI-powered video editor"
108
+ echo ""
109
+ echo "Usage: $SCRIPT_NAME [command] [options...]"
110
+ echo ""
111
+ echo "Commands:"
112
+ echo " (no args) Launch AI agent (Claude Code優先)"
113
+ echo " --preview, -pv Start preview server"
114
+ echo " update Check for updates"
115
+ echo " --opencode Use opencode instead of Claude Code"
116
+ echo " --claude, --claudecode Launch Claude Code explicitly"
117
+ echo " -y, --yes Auto-confirm (skip permissions; opencode:--auto / Claude:--permission-mode acceptEdits)"
118
+ echo " -h, --help, -? Show this help"
119
+ echo ""
120
+ echo "Typical workflow:"
121
+ echo " 1. mkdir ~/my-first-video && cd ~/my-first-video"
122
+ echo " 2. $SCRIPT_NAME # AI agent (project auto-created)"
123
+ echo " 3. $SCRIPT_NAME --preview # Preview server (別の端末で)"
124
+ echo ""
125
+ echo "Examples:"
126
+ echo " $SCRIPT_NAME # Claude Code起動"
127
+ echo " $SCRIPT_NAME -y # Claude Code + auto-confirm"
128
+ echo " $SCRIPT_NAME --opencode -y # opencode + auto-confirm"
129
+ echo " $SCRIPT_NAME --preview # Preview (current dir)"
130
+ echo " $SCRIPT_NAME --preview ~/my-project 3000"
131
+ echo " $SCRIPT_NAME update"
132
+ exit 0 ;;
133
+ --preview|-pv) shift; cmd_preview "$@" ;;
134
+ update) exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs" "update" ;;
135
+ --opencode) shift; exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs" "--opencode" "$@" ;;
136
+ --claude|--claudecode) shift; exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs" "--claude" "$@" ;;
137
+ -y|--yes) shift; exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs" "--yes" "$@" ;;
138
+ *) exec node "$MONOREPO/packages/akari-launcher/bin/akari.mjs" "$@" ;;
139
+ esac