akari-video 1.5.0-beta.2 → 1.5.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/docs/contract-2026-10-15-playbook-v0.md +6 -1
- package/vendor/docs/contract-2026-10-15-script-lines-v0.md +67 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/src/lint/intake.mjs +10 -1
- package/vendor/packages/i18n/locales/en/annotations.json +1 -0
- package/vendor/packages/i18n/locales/en/project.json +1 -0
- package/vendor/packages/i18n/locales/en/transcript.json +12 -0
- package/vendor/packages/i18n/locales/ja/annotations.json +1 -0
- package/vendor/packages/i18n/locales/ja/project.json +1 -0
- package/vendor/packages/i18n/locales/ja/transcript.json +12 -0
- package/vendor/packages/project-scaffold/src/index.mjs +75 -11
- package/vendor/packages/schemas/playbook.schema.json +20 -0
- package/vendor/packages/schemas/script.schema.json +31 -0
- package/vendor/packages/schemas/test/validate-playbook.test.mjs +26 -0
- package/vendor/packages/schemas/test/validate-script.test.mjs +29 -0
- package/vendor/packages/schemas/validate-playbook.mjs +17 -0
- package/vendor/packages/schemas/validate-script.mjs +55 -0
- package/vendor/skills/record-playbook/SKILL.md +5 -4
- package/vendor/skills/record-playbook/bin/record-playbook.mjs +225 -5
- package/vendor/skills/record-playbook/test/record-beats.test.mjs +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.3",
|
|
4
4
|
"description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"docs/contract-2026-10-11-ui-i18n.md",
|
|
92
92
|
"docs/contract-2026-10-12-caption-look-v1.md",
|
|
93
93
|
"docs/contract-2026-10-15-playbook-v0.md",
|
|
94
|
+
"docs/contract-2026-10-15-script-lines-v0.md",
|
|
94
95
|
"packages/akari-ear/package.json",
|
|
95
96
|
"packages/akari-ear/README.md",
|
|
96
97
|
"packages/akari-launcher/package.json",
|
|
@@ -48,7 +48,8 @@ updated: 2026-10-15
|
|
|
48
48
|
| 確認シート | `<project>/.akari/reports/playbook-sheet.html`(読み取り専用・派生) | `akari new --from` |
|
|
49
49
|
|
|
50
50
|
- 一覧の解決順は **チャンネル → 手元** の 2 択だけ(`create-project` の雛形解決と同じ規律。他の経路を作らない)
|
|
51
|
-
- `preview/`
|
|
51
|
+
- `preview/` は任意(下の項)
|
|
52
|
+
- `preview` のパスはすべて **playbook.json からの相対**。棚(手元・チャンネル)では `preview/cover.png`(任意)と `preview/frames/01.png`〜`05.png`(重要な場面のコマ 3〜5 枚・長辺 640px 以下・作り方の画面比)を置く。表紙が無ければ `frames` の 1 枚目を表紙にする。動画(sample.mp4)は持たない(重いため)。プロジェクトの中では `planning/playbook-preview/frames/` に置く
|
|
52
53
|
- `<id>` は英小文字・数字・ハイフン。同じ `id` は版で区別する(`version` 整数)
|
|
53
54
|
|
|
54
55
|
## 2. 形 — playbook.json
|
|
@@ -94,6 +95,8 @@ updated: 2026-10-15
|
|
|
94
95
|
- 必須: `version`(0 固定。原則 1)・`kind`(`"akari-playbook"`)・`id`・`name`・`playbookVersion`・`depth`・
|
|
95
96
|
`category`・`steps[]`・`settings`・`license`・`provenance`。`tags` / `requires` / `placeholders` / `parts` は
|
|
96
97
|
省略可(depth 1 では `placeholders` と `parts` を持たない)
|
|
98
|
+
- 任意: `preview`(`{ "cover"?: string, "frames"?: string[] }`。パスは上の規則)・`example`(`true` なら見本として配る例。画面に「例」の印を出す)
|
|
99
|
+
- 任意: `beats`(`{ id, at, duration, title, note?, placeholder? }[]`・秒。作り方の内訳。始めたとき空のキャンバスと台本の予定の行になる。script-lines-v0 §5)
|
|
97
100
|
- `category` は 1 つだけ。語彙は閉じる: `film` / `short-drama` / `mv` / `explainer` / `product` / `vlog` / `cm` /
|
|
98
101
|
`education` / `clip` / `logo`。足すときは本表に追記してから
|
|
99
102
|
- `tags` の 8 軸は開いた語彙(自由に足せる)。軸の名前だけ固定: `form` / `length` / `technique` / `tools` /
|
|
@@ -118,6 +121,7 @@ updated: 2026-10-15
|
|
|
118
121
|
## 流れ ← steps を番号つきで。確認点は「☑ ここで確認」と明記
|
|
119
122
|
## 設定値 ← settings を表で(キー / 値 / 意味)
|
|
120
123
|
## 必要な素材(仮枠) ← placeholders を表で(役割 / 種類 / 尺の目安 / 見本)
|
|
124
|
+
## 内訳 ← beats を表で(時刻 / 見出し / メモ / 仮枠)
|
|
121
125
|
## 使った部品 ← parts を表で(種類 / 名前 / 入手先 / ライセンス)
|
|
122
126
|
## 要る道具 ← requires(CLI の版・道具・スキル)。無いときの代案を 1 行
|
|
123
127
|
## ライセンス ← license と、外部由来を仮枠にした旨
|
|
@@ -125,6 +129,7 @@ updated: 2026-10-15
|
|
|
125
129
|
```
|
|
126
130
|
|
|
127
131
|
章の追加は末尾のみ(additive-only)。章の名前は上の表記で固定し、読み手はこの見出しで章を引く。
|
|
132
|
+
例外として「内訳」の 1 章だけは「必要な素材(仮枠)」の直後に置く。
|
|
128
133
|
|
|
129
134
|
## 4. 記録する — プロジェクト → 作り方(`record-playbook` スキル)
|
|
130
135
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
lifecycle: accepted
|
|
3
|
+
created: 2026-10-15
|
|
4
|
+
updated: 2026-10-15
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# script-lines-v0(台本の「予定」と「メモ」の行)契約
|
|
8
|
+
|
|
9
|
+
- 日付: 2026-10-15
|
|
10
|
+
- 状態: **v0 受理(2026-10-15 オーナー GO)**
|
|
11
|
+
- 前提: `contract-2026-07-25-project-structure-v0.md`(`planning/` は企画・プランの層)/ `contract-2026-09-24-caption-runs-v0.md`(字幕は `captions.json`)/
|
|
12
|
+
`contract-2026-10-15-playbook-v0.md`(作り方。本契約の「予定」は作り方の内訳 `beats` から作られる)/ `contract-2026-09-25-canvas-v0.md`(空のキャンバス = 区間を持つ仮枠)
|
|
13
|
+
- 発端: 台本パネルは字幕の行しか持たず、「ここに何を入れる予定か」「気づいたメモ」を時刻つきで残せない。作り方の内訳(何秒に何を)も、台本とタイムラインに出す場所が無い
|
|
14
|
+
- スコープ: 予定とメモの行の **置き場・形・台本パネルとタイムラインでの見せ方・作り方からの生成**
|
|
15
|
+
- 非スコープ: 字幕(`captions.json`)の形の変更 / 予定の行から自動でカットを作ること / 複数人の同時編集
|
|
16
|
+
|
|
17
|
+
## 0. 位置づけ
|
|
18
|
+
|
|
19
|
+
台本の行は 3 種類。**字幕**(`captions.json`。今のまま)・**予定**(ここに何を入れるつもりか)・**メモ**(気づき・指示)。
|
|
20
|
+
予定とメモは `planning/script.json` に持ち、台本パネルは 3 種を時刻順に 1 本の列で見せる。字幕は今までどおり `captions.json` が正本で、本契約は触らない。
|
|
21
|
+
|
|
22
|
+
## 1. 置き場
|
|
23
|
+
|
|
24
|
+
- `<project>/planning/script.json`(無ければ予定もメモも無い。台本パネルは字幕だけで今までどおり動く)
|
|
25
|
+
- シーンを持つプロジェクト(project-manifest v0)では `scenes/<scene>/planning/script.json`。台本パネルは開いているシーンの 1 本を読む
|
|
26
|
+
|
|
27
|
+
## 2. 形
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"version": 0,
|
|
32
|
+
"kind": "akari-script",
|
|
33
|
+
"lines": [
|
|
34
|
+
{ "id": "p-hook", "kind": "plan", "at": 0, "duration": 3, "text": "つかみ: 結論を先に言う", "placeholder": "talking-head", "beat": "hook" },
|
|
35
|
+
{ "id": "p-body", "kind": "plan", "at": 3, "duration": 40, "text": "本題: 3 つのポイント", "placeholder": "screen", "done": false },
|
|
36
|
+
{ "id": "m-01", "kind": "memo", "at": 12.5, "text": "ここで図解を差し込む" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `version`: 整数 0(原則 1)。`kind`: 固定 `"akari-script"`
|
|
42
|
+
- `lines[]`: 必須(空でよい)。各行は `id`(ファイル内で一意)・`kind`(`plan` / `memo`)・`at`(秒・0 以上)・`text`(1〜500 文字)が必須
|
|
43
|
+
- 任意: `duration`(秒・0 より大。予定の区間)/ `placeholder`(作り方の仮枠の `id`)/ `beat`(作り方の内訳の `id`)/ `done`(予定が済んだか)/ `author`(`user` / `ai` / `playbook`)
|
|
44
|
+
- 並び: `at` → `id` の安定ソートで保存。書き込みは tmp + rename。読み手は tolerant(未知フィールドは保持、壊れた行は捨てて残りを読む)
|
|
45
|
+
- スキーマ: `packages/schemas/script.schema.json`(closed object・additive-only)と `validate-script.mjs`
|
|
46
|
+
|
|
47
|
+
## 3. 台本パネル(右ドックの「台本」)
|
|
48
|
+
|
|
49
|
+
- 字幕・予定・メモを `at` の順に 1 本の列で出す。行の頭に種類の印(字幕 / 予定 / メモ)。予定は `duration` があれば区間(`0:00–0:03`)を出し、`done` はチェックで切り替える
|
|
50
|
+
- 「+ メモ」で今の再生位置にメモを足す(`author: user`)。予定・メモの文字はその場で直せる。削除はメニューから
|
|
51
|
+
- 行を押すとその時刻へ移動(字幕の行と同じ)。字幕の既存の操作(字幕を作る・カットを整える・QC)は変えない
|
|
52
|
+
- 表示の切り替え: 「予定とメモを出す」のオン / オフ(既定オン)。オフなら今までの字幕だけの台本
|
|
53
|
+
|
|
54
|
+
## 4. タイムライン
|
|
55
|
+
|
|
56
|
+
- タイムラインの最上段に「台本」の段を 1 本足し、予定を区間の帯、メモを点の印で出す(`script.json` が無ければ段を出さない)。押すとその時刻へ移動。ここでは編集しない(直すのは台本パネル)
|
|
57
|
+
|
|
58
|
+
## 5. 作り方からの生成
|
|
59
|
+
|
|
60
|
+
- 作り方(playbook.json)の任意項目 `beats[]`(`{ id, at, duration, title, note?, placeholder? }`・秒)を、`applyPlaybook` が
|
|
61
|
+
予定の行(`kind: plan`・`text` = `title`(`note` があれば「title: note」)・`author: playbook`)として `planning/script.json` に書く。
|
|
62
|
+
同時に、内訳 1 つ = 空のキャンバス 1 つ(名前 = title・`canvas.intent` = note か title)にして仮枠の段に並べる(`beats` が無いときは従来どおり仮枠ごとに 1 つ)
|
|
63
|
+
- `record-playbook` は、終わったプロジェクトの `planning/script.json` の予定の行(あれば)と章・見出しから `beats` を起こす
|
|
64
|
+
|
|
65
|
+
## 6. 開けた問い
|
|
66
|
+
|
|
67
|
+
1. 予定の行を「済」にしたら、対応する空のキャンバスを畳むか(v0 は何もしない)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.3",
|
|
4
4
|
"description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { addFinding, isPositiveNumber, isRecord, relativePath } from "./shared.mjs";
|
|
4
5
|
import { t } from "../../../akari-tools/src/decision-log/i18n.mjs";
|
|
@@ -152,12 +153,20 @@ export function validateIntake(intake, findings, paths) {
|
|
|
152
153
|
`${intakeRelative}#submitted_at`,
|
|
153
154
|
);
|
|
154
155
|
}
|
|
156
|
+
let emptyProject = false;
|
|
157
|
+
try {
|
|
158
|
+
const edit = JSON.parse(readFileSync(paths.editPath, "utf8"));
|
|
159
|
+
const hasMedia = (items) => Array.isArray(items) && items.some(item =>
|
|
160
|
+
item?.source?.kind === "media" || hasMedia(item?.items));
|
|
161
|
+
emptyProject = (edit.sources === undefined || (Array.isArray(edit.sources) && edit.sources.length === 0))
|
|
162
|
+
&& !edit.tracks?.some(track => hasMedia(track?.items));
|
|
163
|
+
} catch { /* If edit.json cannot be read, retain the existing warning. */ }
|
|
155
164
|
intakeFinding(
|
|
156
165
|
findings,
|
|
157
166
|
"intake.status",
|
|
158
167
|
"intake.json is still a draft; the plan is not confirmed yet — settle tasks / target / autonomy with the user (form or conversation) before following it",
|
|
159
168
|
intakeRelative,
|
|
160
|
-
"warning",
|
|
169
|
+
emptyProject ? "info" : "warning",
|
|
161
170
|
);
|
|
162
171
|
}
|
|
163
172
|
}
|
|
@@ -1029,6 +1029,7 @@
|
|
|
1029
1029
|
"annotations.timeline.text.thisTransitionWillNotBeExportedBecauseThere": "This transition will not be exported because there is a gap between it and the next clip.",
|
|
1030
1030
|
"annotations.timeline.text.thumbnailsAndWaveformsAreNotDisplayedBecauseFfmpeg": "Thumbnails and waveforms are not displayed because ffmpeg is not found (other operations can be used normally)",
|
|
1031
1031
|
"annotations.timeline.text.timeline": "Timeline",
|
|
1032
|
+
"annotations.timeline.text.script": "Script",
|
|
1032
1033
|
"annotations.timeline.text.timelineEmptyState": "Timeline empty state",
|
|
1033
1034
|
"annotations.timeline.text.trackDeleted": "Track deleted.",
|
|
1034
1035
|
"annotations.timeline.text.trackNameChanged": "Track name changed.",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"project.editCheck.result.checkCountIssuesAction_other": "Check: {count} issues",
|
|
66
66
|
"project.editCheck.result.checkingStatus": "Checking…",
|
|
67
67
|
"project.editCheck.result.checkNoIssuesAction": "Check: No issues",
|
|
68
|
+
"project.editCheck.result.checkNoIssuesWithInfoAction": "Check: No issues (info {count})",
|
|
68
69
|
"project.editCheck.result.checkNotRunAction": "Check: Not run",
|
|
69
70
|
"project.editCheck.result.editCheckLengthIssuesLabel_one": "[Edit check] {length} issue:\n{value2}",
|
|
70
71
|
"project.editCheck.result.editCheckLengthIssuesLabel_other": "[Edit check] {length} issues:\n{value2}",
|
|
@@ -227,6 +227,18 @@
|
|
|
227
227
|
"transcript.daihon.dock.linesSelectedWithCountLabel_one": "{value1} line selected",
|
|
228
228
|
"transcript.daihon.dock.linesSelectedWithCountLabel_other": "{value1} lines selected",
|
|
229
229
|
"transcript.daihon.editor.add0point5SecPauseLabel": "Add a 0.5 sec pause",
|
|
230
|
+
"transcript.daihon.scriptLines.caption": "Caption",
|
|
231
|
+
"transcript.daihon.scriptLines.plan": "Plan",
|
|
232
|
+
"transcript.daihon.scriptLines.memo": "Memo",
|
|
233
|
+
"transcript.daihon.scriptLines.addMemo": "+ Memo",
|
|
234
|
+
"transcript.daihon.scriptLines.addMemoAtPlayhead": "Add a memo at the playhead",
|
|
235
|
+
"transcript.daihon.scriptLines.newMemo": "New memo",
|
|
236
|
+
"transcript.daihon.scriptLines.show": "Show plans and memos",
|
|
237
|
+
"transcript.daihon.scriptLines.checkedShow": "✓ Show plans and memos",
|
|
238
|
+
"transcript.daihon.scriptLines.done": "Done",
|
|
239
|
+
"transcript.daihon.scriptLines.actions": "Line actions",
|
|
240
|
+
"transcript.daihon.scriptLines.edit": "Edit text",
|
|
241
|
+
"transcript.daihon.scriptLines.delete": "Delete",
|
|
230
242
|
"transcript.daihon.editor.added0point5SecPauseBeforeWithValueNotice": "Added a 0.5 sec pause before “{value1}”.",
|
|
231
243
|
"transcript.daihon.editor.addedMarkAnnotationsTabNotice": "Added a mark to the Annotations tab.",
|
|
232
244
|
"transcript.daihon.editor.addLineBelowLabel": "Add line below",
|
|
@@ -1029,6 +1029,7 @@
|
|
|
1029
1029
|
"annotations.timeline.text.thisTransitionWillNotBeExportedBecauseThere": "このトランジションは次のクリップとの間にすき間があるため書き出されません。",
|
|
1030
1030
|
"annotations.timeline.text.thumbnailsAndWaveformsAreNotDisplayedBecauseFfmpeg": "ffmpeg が見つからないため、サムネイルと波形は表示されません(他の操作は通常どおり使えます)",
|
|
1031
1031
|
"annotations.timeline.text.timeline": "タイムライン",
|
|
1032
|
+
"annotations.timeline.text.script": "台本",
|
|
1032
1033
|
"annotations.timeline.text.timelineEmptyState": "タイムライン空状態",
|
|
1033
1034
|
"annotations.timeline.text.trackDeleted": "トラックを削除しました。",
|
|
1034
1035
|
"annotations.timeline.text.trackNameChanged": "トラック名を変更しました。",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"project.editCheck.result.checkCountIssuesAction_other": "チェック: 指摘 {count} 件",
|
|
66
66
|
"project.editCheck.result.checkingStatus": "確認中…",
|
|
67
67
|
"project.editCheck.result.checkNoIssuesAction": "チェック: 問題なし",
|
|
68
|
+
"project.editCheck.result.checkNoIssuesWithInfoAction": "チェック: 問題なし(情報 {count})",
|
|
68
69
|
"project.editCheck.result.checkNotRunAction": "チェック: 未実行",
|
|
69
70
|
"project.editCheck.result.editCheckLengthIssuesLabel_one": "【編集内容のチェック】{length} 件の指摘:\n{value2}",
|
|
70
71
|
"project.editCheck.result.editCheckLengthIssuesLabel_other": "【編集内容のチェック】{length} 件の指摘:\n{value2}",
|
|
@@ -227,6 +227,18 @@
|
|
|
227
227
|
"transcript.daihon.dock.linesSelectedWithCountLabel_one": "{value1} 行を選択中",
|
|
228
228
|
"transcript.daihon.dock.linesSelectedWithCountLabel_other": "{value1} 行を選択中",
|
|
229
229
|
"transcript.daihon.editor.add0point5SecPauseLabel": "0.5 秒の間を追加",
|
|
230
|
+
"transcript.daihon.scriptLines.caption": "字幕",
|
|
231
|
+
"transcript.daihon.scriptLines.plan": "予定",
|
|
232
|
+
"transcript.daihon.scriptLines.memo": "メモ",
|
|
233
|
+
"transcript.daihon.scriptLines.addMemo": "+ メモ",
|
|
234
|
+
"transcript.daihon.scriptLines.addMemoAtPlayhead": "今の再生位置にメモを追加",
|
|
235
|
+
"transcript.daihon.scriptLines.newMemo": "新しいメモ",
|
|
236
|
+
"transcript.daihon.scriptLines.show": "予定とメモを出す",
|
|
237
|
+
"transcript.daihon.scriptLines.checkedShow": "✓ 予定とメモを出す",
|
|
238
|
+
"transcript.daihon.scriptLines.done": "済",
|
|
239
|
+
"transcript.daihon.scriptLines.actions": "行の操作",
|
|
240
|
+
"transcript.daihon.scriptLines.edit": "文字を編集",
|
|
241
|
+
"transcript.daihon.scriptLines.delete": "削除",
|
|
230
242
|
"transcript.daihon.editor.added0point5SecPauseBeforeWithValueNotice": "「{value1}」の前に 0.5 秒の間を入れました",
|
|
231
243
|
"transcript.daihon.editor.addedMarkAnnotationsTabNotice": "注釈タブにマークを追加しました",
|
|
232
244
|
"transcript.daihon.editor.addLineBelowLabel": "下に行を足す",
|
|
@@ -558,6 +558,7 @@ function renderPlaybookSheetHtml(playbook, missingTools) {
|
|
|
558
558
|
const list = (items) => items.length ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : '<p>なし</p>';
|
|
559
559
|
const steps = playbook.steps.map((step, index) => `${index + 1}. ${step.title}${step.checkpoint ? ' ☑ ここで確認' : ''}`);
|
|
560
560
|
const placeholders = (playbook.placeholders ?? []).map(item => `${item.role} / ${item.kind} / 見本 ${item.sample ? `あり (${item.sample.category}/${item.sample.id})` : 'なし'}`);
|
|
561
|
+
const beats = (playbook.beats ?? []).map(beat => `${beat.at}–${beat.at + beat.duration} 秒 / ${beat.title}${beat.note ? ` / ${beat.note}` : ''}`);
|
|
561
562
|
const parts = (playbook.parts ?? []).map(item => `${item.kind}: ${item.id} / ${item.source} / ${item.license} / Pro ${item.tier === 'pro' ? '要' : item.tier === 'free' ? '不要' : '不明'}`);
|
|
562
563
|
const settings = Object.entries(playbook.settings).map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
|
|
563
564
|
const provenance = playbook.provenance;
|
|
@@ -581,6 +582,7 @@ ${REPORT_STYLE}
|
|
|
581
582
|
${row('流れ(確認点つき)', list(steps))}
|
|
582
583
|
${row('設定値の数と主な値', `<p>${settings.length} 件</p>${list(settings)}`)}
|
|
583
584
|
${row('仮枠(役割・種類・見本の有無)', list(placeholders))}
|
|
585
|
+
${playbook.beats?.length ? row('内訳', list(beats)) : ''}
|
|
584
586
|
${row('使った部品(入手先・ライセンス・Pro 要否)', list(parts))}
|
|
585
587
|
${row('要る道具(持っていないものを明記)', list((playbook.requires?.tools ?? []).map(tool => `${tool}: ${missingTools.includes(tool) ? '無い' : 'あり'}`)))}
|
|
586
588
|
${row('ライセンス', escapeHtml(playbook.license))}
|
|
@@ -711,26 +713,71 @@ function applySettings(edit, settings) {
|
|
|
711
713
|
}
|
|
712
714
|
}
|
|
713
715
|
|
|
714
|
-
function addPlaceholders(edit, placeholders) {
|
|
716
|
+
function addPlaceholders(edit, placeholders, beats) {
|
|
715
717
|
const fps = edit.output.fps;
|
|
716
|
-
const
|
|
718
|
+
const hasBeats = Array.isArray(beats) && beats.length > 0;
|
|
719
|
+
const track = { id: 'playbook-placeholders', name: '仮枠(映像)', lane: 'visual', items: [] };
|
|
720
|
+
const audioTrack = { id: 'playbook-placeholders-audio', name: '仮枠(音)', lane: 'visual', items: [] };
|
|
717
721
|
let at = 0;
|
|
722
|
+
if (hasBeats) {
|
|
723
|
+
for (const beat of [...beats].sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
|
|
724
|
+
const start = Math.round(beat.at * fps);
|
|
725
|
+
const duration = Math.max(1, Math.round(beat.duration * fps));
|
|
726
|
+
track.items.push({
|
|
727
|
+
id: `playbook-${beat.id}`, at: start, duration, name: beat.title,
|
|
728
|
+
source: { kind: 'group', canvas: { origin: 'plan', durationMode: 'fixed', intent: beat.note || beat.title,
|
|
729
|
+
background: { type: 'none' } } }, items: []
|
|
730
|
+
});
|
|
731
|
+
at = Math.max(at, start + duration);
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
for (const placeholder of placeholders) {
|
|
735
|
+
if (placeholder.kind === 'audio') continue;
|
|
736
|
+
const duration = Math.max(1, Math.round((placeholder.durationHintSec ?? 5) * fps));
|
|
737
|
+
// Empty planned canvas items are valid edit-v2 nodes without a media path. A
|
|
738
|
+
// sample is a catalog reference, never an asset copied into the project.
|
|
739
|
+
track.items.push({
|
|
740
|
+
id: `playbook-${placeholder.id}`, at, duration,
|
|
741
|
+
name: placeholder.sample
|
|
742
|
+
? `${placeholder.role} (${placeholder.sample.category}/${placeholder.sample.id})`
|
|
743
|
+
: placeholder.role,
|
|
744
|
+
source: { kind: 'group', canvas: { origin: 'plan', durationMode: 'fixed', intent: placeholder.role,
|
|
745
|
+
background: { type: 'none' } } }, items: []
|
|
746
|
+
});
|
|
747
|
+
at += duration;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (track.items.length) edit.tracks.push(track);
|
|
718
751
|
for (const placeholder of placeholders) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
id: `playbook-${placeholder.id}`, at, duration,
|
|
752
|
+
if (placeholder.kind !== 'audio') continue;
|
|
753
|
+
const duration = at || Math.max(1, Math.round((placeholder.durationHintSec ?? edit.output.target_duration_sec ?? 5) * fps));
|
|
754
|
+
audioTrack.items.push({
|
|
755
|
+
id: `playbook-${placeholder.id}`, at: 0, duration,
|
|
724
756
|
name: placeholder.sample
|
|
725
757
|
? `${placeholder.role} (${placeholder.sample.category}/${placeholder.sample.id})`
|
|
726
758
|
: placeholder.role,
|
|
727
759
|
source: { kind: 'group', canvas: { origin: 'plan', durationMode: 'fixed', intent: placeholder.role,
|
|
728
760
|
background: { type: 'none' } } }, items: []
|
|
729
761
|
});
|
|
730
|
-
at += duration;
|
|
731
762
|
}
|
|
732
|
-
if (
|
|
733
|
-
return placeholders.length;
|
|
763
|
+
if (audioTrack.items.length) edit.tracks.push(audioTrack);
|
|
764
|
+
return hasBeats ? beats.length + placeholders.filter(item => item.kind === 'audio').length : placeholders.length;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function mergePlaybookScript(existing, beats) {
|
|
768
|
+
const lines = (Array.isArray(existing?.lines) ? existing.lines : []).filter(line => line?.author !== 'playbook');
|
|
769
|
+
const ids = new Set(lines.map(line => line.id));
|
|
770
|
+
for (const beat of beats) {
|
|
771
|
+
const base = `p-${beat.id}`;
|
|
772
|
+
let id = base;
|
|
773
|
+
for (let suffix = 2; ids.has(id); suffix++) id = `${base}-${suffix}`;
|
|
774
|
+
ids.add(id);
|
|
775
|
+
lines.push({ id, kind: 'plan', at: beat.at, duration: beat.duration,
|
|
776
|
+
text: beat.note ? `${beat.title}: ${beat.note}` : beat.title,
|
|
777
|
+
...(beat.placeholder ? { placeholder: beat.placeholder } : {}), beat: beat.id, author: 'playbook' });
|
|
778
|
+
}
|
|
779
|
+
lines.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
780
|
+
return { ...(existing ?? {}), version: 0, kind: 'akari-script', lines };
|
|
734
781
|
}
|
|
735
782
|
|
|
736
783
|
/** Apply a validated playbook to an already scaffolded project. */
|
|
@@ -744,8 +791,17 @@ export async function applyPlaybook({ projectDir, playbookDir, now = new Date(),
|
|
|
744
791
|
]);
|
|
745
792
|
const required = ['version', 'kind', 'id', 'name', 'playbookVersion', 'depth', 'category', 'steps', 'settings', 'license', 'provenance'];
|
|
746
793
|
const headings = ['目的とできあがり', '流れ', '設定値', '必要な素材(仮枠)', '使った部品', '要る道具', 'ライセンス', '出どころ'];
|
|
794
|
+
const beats = playbook.beats;
|
|
795
|
+
const invalidBeats = beats !== undefined && (!Array.isArray(beats) || new Set(beats.map(beat => beat?.id)).size !== beats.length
|
|
796
|
+
|| beats.some(beat => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(beat?.id ?? '')
|
|
797
|
+
|| !Number.isFinite(beat.at) || beat.at < 0 || !Number.isFinite(beat.duration) || beat.duration <= 0
|
|
798
|
+
|| typeof beat.title !== 'string' || !beat.title.trim()
|
|
799
|
+
|| (beat.note !== undefined && (typeof beat.note !== 'string' || !beat.note.trim()))
|
|
800
|
+
|| (beat.placeholder !== undefined && typeof beat.placeholder !== 'string')
|
|
801
|
+
|| (beat.note ? `${beat.title}: ${beat.note}` : beat.title).length > 500));
|
|
747
802
|
if (required.some(key => playbook[key] === undefined) || playbook.version !== 0 || playbook.kind !== 'akari-playbook'
|
|
748
803
|
|| !Array.isArray(playbook.steps) || !playbook.provenance
|
|
804
|
+
|| invalidBeats
|
|
749
805
|
|| (playbook.placeholders ?? []).some(item => ['own', 'site'].includes(item.sample?.source))
|
|
750
806
|
|| (playbook.parts ?? []).some(item => ['own', 'site'].includes(item.source))
|
|
751
807
|
|| headings.some(heading => !new RegExp(`^## ${heading}$`, 'm').test(markdown))) {
|
|
@@ -764,7 +820,14 @@ export async function applyPlaybook({ projectDir, playbookDir, now = new Date(),
|
|
|
764
820
|
}
|
|
765
821
|
playbook.provenance.appliedFrom = { path: source, playbookVersion: playbook.playbookVersion, appliedAt: now.toISOString() };
|
|
766
822
|
applySettings(edit, playbook.settings);
|
|
767
|
-
const
|
|
823
|
+
const activeBeats = playbook.beats?.length ? playbook.beats : undefined;
|
|
824
|
+
const placeholdersAdded = addPlaceholders(edit, playbook.placeholders ?? [], activeBeats);
|
|
825
|
+
const scriptPath = path.join(planning, 'script.json');
|
|
826
|
+
const existingScript = activeBeats ? await fs.readFile(scriptPath, 'utf8').then(JSON.parse).catch(error => {
|
|
827
|
+
if (error.code === 'ENOENT') return null;
|
|
828
|
+
throw error;
|
|
829
|
+
}) : null;
|
|
830
|
+
const script = activeBeats ? mergePlaybookScript(existingScript, activeBeats) : null;
|
|
768
831
|
const missingTools = [];
|
|
769
832
|
for (const tool of playbook.requires?.tools ?? []) if (!await hasTool(tool)) missingTools.push(tool);
|
|
770
833
|
await fs.mkdir(planning, { recursive: true });
|
|
@@ -772,6 +835,7 @@ export async function applyPlaybook({ projectDir, playbookDir, now = new Date(),
|
|
|
772
835
|
await fs.writeFile(playbookMdPath, markdown, { flag: 'wx' });
|
|
773
836
|
await writeJsonAtomic(playbookJsonPath, playbook);
|
|
774
837
|
await writeJsonAtomic(editPath, edit);
|
|
838
|
+
if (script) await writeJsonAtomic(scriptPath, script);
|
|
775
839
|
await fs.writeFile(sheetPath, renderReportHtml(null, { playbook, missingTools }), { flag: 'wx' });
|
|
776
840
|
return { playbookJsonPath, playbookMdPath, sheetPath, placeholdersAdded, missingTools };
|
|
777
841
|
}
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
"playbookVersion": { "type": "integer", "minimum": 1 },
|
|
14
14
|
"depth": { "enum": [1, 2] },
|
|
15
15
|
"category": { "enum": ["film", "short-drama", "mv", "explainer", "product", "vlog", "cm", "education", "clip", "logo"] },
|
|
16
|
+
"preview": {
|
|
17
|
+
"type": "object", "additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"cover": { "type": "string" },
|
|
20
|
+
"frames": { "type": "array", "maxItems": 5, "items": { "type": "string" } }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"example": { "type": "boolean" },
|
|
16
24
|
"tags": {
|
|
17
25
|
"type": "object", "additionalProperties": false,
|
|
18
26
|
"properties": {
|
|
@@ -50,6 +58,18 @@
|
|
|
50
58
|
"sample": { "$ref": "#/$defs/sample" }
|
|
51
59
|
}
|
|
52
60
|
} },
|
|
61
|
+
"beats": { "type": "array", "items": {
|
|
62
|
+
"type": "object", "additionalProperties": false,
|
|
63
|
+
"required": ["id", "at", "duration", "title"],
|
|
64
|
+
"properties": {
|
|
65
|
+
"id": { "$ref": "#/$defs/id" },
|
|
66
|
+
"at": { "type": "number", "minimum": 0 },
|
|
67
|
+
"duration": { "type": "number", "exclusiveMinimum": 0 },
|
|
68
|
+
"title": { "$ref": "#/$defs/text" },
|
|
69
|
+
"note": { "$ref": "#/$defs/text" },
|
|
70
|
+
"placeholder": { "$ref": "#/$defs/id" }
|
|
71
|
+
}
|
|
72
|
+
} },
|
|
53
73
|
"parts": { "type": "array", "items": {
|
|
54
74
|
"type": "object", "additionalProperties": false,
|
|
55
75
|
"required": ["kind", "id", "source", "license"],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:akari-video:schema:script:v0",
|
|
4
|
+
"title": "AKARI Video script lines v0",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "kind", "lines"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": 0 },
|
|
10
|
+
"kind": { "const": "akari-script" },
|
|
11
|
+
"lines": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"required": ["id", "kind", "at", "text"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"id": { "type": "string", "minLength": 1 },
|
|
19
|
+
"kind": { "enum": ["plan", "memo"] },
|
|
20
|
+
"at": { "type": "number", "minimum": 0 },
|
|
21
|
+
"text": { "type": "string", "minLength": 1, "maxLength": 500 },
|
|
22
|
+
"duration": { "type": "number", "exclusiveMinimum": 0 },
|
|
23
|
+
"placeholder": { "type": "string" },
|
|
24
|
+
"beat": { "type": "string" },
|
|
25
|
+
"done": { "type": "boolean" },
|
|
26
|
+
"author": { "enum": ["user", "ai", "playbook"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -32,3 +32,29 @@ test('own part fails, unknown skill warns, missing heading fails', async () => {
|
|
|
32
32
|
assert.match(result.errors.join(' '), /要る道具/);
|
|
33
33
|
assert.match(result.warnings.join(' '), /no-such-skill/);
|
|
34
34
|
});
|
|
35
|
+
test('relative preview paths and example pass', async () => {
|
|
36
|
+
const value = structuredClone(sample);
|
|
37
|
+
value.preview = { cover: 'preview/cover.png', frames: ['preview/frames/01.png', 'preview/frames/02.png', 'preview/frames/03.png'] };
|
|
38
|
+
value.example = true;
|
|
39
|
+
assert.equal((await validatePlaybook(value, { markdown })).ok, true);
|
|
40
|
+
});
|
|
41
|
+
test('parent traversal in preview fails', async () => {
|
|
42
|
+
const value = structuredClone(sample);
|
|
43
|
+
value.preview = { frames: ['../outside.png'] };
|
|
44
|
+
assert.equal((await validatePlaybook(value, { markdown })).ok, false);
|
|
45
|
+
});
|
|
46
|
+
test('beats accept seconds and warn about an unknown placeholder', async () => {
|
|
47
|
+
const value = structuredClone(sample);
|
|
48
|
+
value.beats = [{ id: 'hook', at: 0, duration: 3, title: 'つかみ', placeholder: 'missing' }];
|
|
49
|
+
const result = await validatePlaybook(value, { markdown: `${markdown}\n## 内訳\n` });
|
|
50
|
+
assert.equal(result.ok, true, result.errors.join('; '));
|
|
51
|
+
assert.match(result.warnings.join(' '), /missing/);
|
|
52
|
+
});
|
|
53
|
+
test('beats reject negative starts and nonpositive durations', async () => {
|
|
54
|
+
const value = structuredClone(sample);
|
|
55
|
+
value.beats = [{ id: 'hook', at: -1, duration: 0, title: 'つかみ' }];
|
|
56
|
+
const result = await validatePlaybook(value, { markdown: `${markdown}\n## 内訳\n` });
|
|
57
|
+
assert.equal(result.ok, false);
|
|
58
|
+
assert.match(result.errors.join(' '), /beats\/0\/at/);
|
|
59
|
+
assert.match(result.errors.join(' '), /beats\/0\/duration/);
|
|
60
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { readScript, validateScript } from '../validate-script.mjs';
|
|
4
|
+
|
|
5
|
+
const sample = { version: 0, kind: 'akari-script', lines: [
|
|
6
|
+
{ id: 'p-hook', kind: 'plan', at: 0, duration: 3, text: 'Hook', placeholder: 'talking-head', beat: 'hook' },
|
|
7
|
+
{ id: 'm-01', kind: 'memo', at: 12.5, text: 'Add diagram' }
|
|
8
|
+
] };
|
|
9
|
+
|
|
10
|
+
test('valid script passes', () => assert.equal(validateScript(sample).ok, true));
|
|
11
|
+
test('missing required field fails', () => {
|
|
12
|
+
const value = structuredClone(sample); delete value.lines[0].text;
|
|
13
|
+
assert.equal(validateScript(value).ok, false);
|
|
14
|
+
});
|
|
15
|
+
test('kind outside vocabulary fails', () => {
|
|
16
|
+
const value = structuredClone(sample); value.lines[0].kind = 'caption';
|
|
17
|
+
assert.equal(validateScript(value).ok, false);
|
|
18
|
+
});
|
|
19
|
+
test('tolerant reader skips broken lines and keeps unknown fields', () => {
|
|
20
|
+
const value = { ...sample, extension: { future: true }, lines: [
|
|
21
|
+
{ id: 'bad', kind: 'memo', at: -1, text: 'bad' },
|
|
22
|
+
{ ...sample.lines[1], extra: 42 }, sample.lines[0],
|
|
23
|
+
{ ...sample.lines[1], text: 'duplicate' }
|
|
24
|
+
] };
|
|
25
|
+
const read = readScript(value);
|
|
26
|
+
assert.deepEqual(read.lines.map(line => line.id), ['p-hook', 'm-01']);
|
|
27
|
+
assert.equal(read.lines[1].extra, 42);
|
|
28
|
+
assert.deepEqual(read.extension, { future: true });
|
|
29
|
+
});
|
|
@@ -15,9 +15,25 @@ export async function validatePlaybook(playbook, { markdown, skillsDir = path.re
|
|
|
15
15
|
if (!checkSchema(playbook)) {
|
|
16
16
|
errors.push(...checkSchema.errors.map(error => `${error.instancePath || '/'} ${error.message}`));
|
|
17
17
|
}
|
|
18
|
+
for (const previewPath of [playbook?.preview?.cover, ...(Array.isArray(playbook?.preview?.frames) ? playbook.preview.frames : [])]) {
|
|
19
|
+
if (typeof previewPath !== 'string') continue;
|
|
20
|
+
if (!previewPath || path.isAbsolute(previewPath) || /^[a-zA-Z]:[\\/]/.test(previewPath)
|
|
21
|
+
|| previewPath.includes('..') || previewPath.startsWith('\\\\')) {
|
|
22
|
+
errors.push(`preview のパスは playbook.json からの相対パスで、.. を含められません: ${previewPath}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
18
25
|
for (const [index, item] of (Array.isArray(playbook?.placeholders) ? playbook.placeholders : []).entries()) {
|
|
19
26
|
if (['own', 'site'].includes(item?.sample?.source)) errors.push(`placeholders[${index}].sample に own / site は使えません`);
|
|
20
27
|
}
|
|
28
|
+
const placeholderIds = new Set((Array.isArray(playbook?.placeholders) ? playbook.placeholders : []).map(item => item?.id));
|
|
29
|
+
const beatIds = new Set();
|
|
30
|
+
for (const [index, beat] of (Array.isArray(playbook?.beats) ? playbook.beats : []).entries()) {
|
|
31
|
+
if (beatIds.has(beat?.id)) errors.push(`beats[${index}].id は重複できません`);
|
|
32
|
+
beatIds.add(beat?.id);
|
|
33
|
+
if (beat?.placeholder && !placeholderIds.has(beat.placeholder)) {
|
|
34
|
+
warnings.push(`beats[${index}].placeholder が見つかりません: ${beat.placeholder}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
for (const [index, item] of (Array.isArray(playbook?.parts) ? playbook.parts : []).entries()) {
|
|
22
38
|
if (['own', 'site'].includes(item?.source)) errors.push(`parts[${index}] に own / site は使えません`);
|
|
23
39
|
}
|
|
@@ -30,6 +46,7 @@ export async function validatePlaybook(playbook, { markdown, skillsDir = path.re
|
|
|
30
46
|
if (markdown !== undefined) {
|
|
31
47
|
const found = [...markdown.matchAll(/^## (.+?)\s*$/gmu)].map(match => match[1].trim());
|
|
32
48
|
for (const heading of headings) if (!found.includes(heading)) errors.push(`playbook.md に見出しがありません: ${heading}`);
|
|
49
|
+
if (playbook?.beats && !found.includes('内訳')) errors.push('playbook.md に見出しがありません: 内訳');
|
|
33
50
|
}
|
|
34
51
|
return { ok: errors.length === 0, errors, warnings };
|
|
35
52
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
6
|
+
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const schema = JSON.parse(await fs.readFile(path.join(here, 'script.schema.json'), 'utf8'));
|
|
9
|
+
const check = new Ajv2020({ allErrors: true, strict: false }).compile(schema);
|
|
10
|
+
|
|
11
|
+
export function validateScript(value) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
if (!check(value)) errors.push(...check.errors.map(error => `${error.instancePath || '/'} ${error.message}`));
|
|
14
|
+
if (Array.isArray(value?.lines)) {
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
value.lines.forEach((line, index) => {
|
|
17
|
+
if (typeof line?.id !== 'string') return;
|
|
18
|
+
if (seen.has(line.id)) errors.push(`/lines/${index}/id must be unique`);
|
|
19
|
+
seen.add(line.id);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return { ok: errors.length === 0, errors };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Tolerant reader: unknown fields survive; malformed and duplicate lines are skipped. */
|
|
26
|
+
export function readScript(value) {
|
|
27
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const lines = (Array.isArray(source.lines) ? source.lines : []).filter(line => {
|
|
30
|
+
if (!line || typeof line !== 'object' || Array.isArray(line)) return false;
|
|
31
|
+
if (typeof line.id !== 'string' || !line.id || seen.has(line.id)) return false;
|
|
32
|
+
if (line.kind !== 'plan' && line.kind !== 'memo') return false;
|
|
33
|
+
if (typeof line.at !== 'number' || !Number.isFinite(line.at) || line.at < 0) return false;
|
|
34
|
+
if (typeof line.text !== 'string' || line.text.length < 1 || line.text.length > 500) return false;
|
|
35
|
+
if (line.duration !== undefined && (typeof line.duration !== 'number' || !Number.isFinite(line.duration) || line.duration <= 0)) return false;
|
|
36
|
+
if (line.done !== undefined && typeof line.done !== 'boolean') return false;
|
|
37
|
+
if (line.author !== undefined && !['user', 'ai', 'playbook'].includes(line.author)) return false;
|
|
38
|
+
if (line.placeholder !== undefined && typeof line.placeholder !== 'string') return false;
|
|
39
|
+
if (line.beat !== undefined && typeof line.beat !== 'string') return false;
|
|
40
|
+
seen.add(line.id);
|
|
41
|
+
return true;
|
|
42
|
+
}).sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
43
|
+
return { ...source, version: 0, kind: 'akari-script', lines };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] && await fs.realpath(path.resolve(process.argv[1])).catch(() => '') === await fs.realpath(fileURLToPath(import.meta.url))) {
|
|
47
|
+
if (!process.argv[2]) { console.error('Usage: node packages/schemas/validate-script.mjs <script.json>'); process.exitCode = 2; }
|
|
48
|
+
else {
|
|
49
|
+
try {
|
|
50
|
+
const result = validateScript(JSON.parse(await fs.readFile(process.argv[2], 'utf8')));
|
|
51
|
+
if (result.ok) console.log(`OK: ${process.argv[2]}`);
|
|
52
|
+
else { for (const error of result.errors) console.error(`ERROR: ${error}`); process.exitCode = 1; }
|
|
53
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -13,12 +13,13 @@ description: Record a finished AKARI Video project's workflow as planning/playbo
|
|
|
13
13
|
2. Read `edit.json` through a script, following `docs/guides/edit-json-access.md`. Do not read the entire file into agent context. Never copy source media, source paths, or the original edit into the playbook.
|
|
14
14
|
3. Group sources by their role. Sum the duration hints for sources serving the same role. For `own` and `site` material, write an empty placeholder without a sample. Do not put `own` or `site` items in `parts`.
|
|
15
15
|
4. Ask the person once for `name`, `category`, and `license` only. Use `CC0-1.0` as the proposed license. Do not ask for each step, role, setting, or part. Fill uncertain wording with `要確認` and refine it from observed evidence.
|
|
16
|
-
5. Keep the
|
|
16
|
+
5. Keep the Markdown headings, including `## 内訳` after `## 必要な素材(仮枠)`, and the JSON schema in sync. For an existing playbook, increment `playbookVersion`. Use atomic writes.
|
|
17
17
|
|
|
18
18
|
## Procedure
|
|
19
19
|
|
|
20
20
|
1. Inspect `decisions.json`, the documents in `planning/`, and report types in `.akari/reports/` for the steps and human checkpoints. If the steps cannot be recovered, use four defaults: place media, confirm script, refine cut, export. Mark uncertain `what` text `要確認`.
|
|
21
21
|
2. Extract starting values from `edit.json`: aspect, fps, target duration, caption style, default transition, and audio roles. Record settings as values to start the next project, not as a copy of every cut.
|
|
22
|
-
3. Examine `edit.json` sources and available `analysis.json` tags or speaker observations to name placeholder roles. Use `.akari/asset-references.json` and `.akari/style-usage.json` to identify reusable parts and licenses. Keep only `builtin`, `lab`, and `ai` parts. Check used skills and recipe `requires` for required tools and skills.
|
|
23
|
-
4. Run `node skills/record-playbook/bin/record-playbook.mjs <project-root> --name <name> --category <category> [--license <license>]` from a repository checkout, or use `.claude/skills/record-playbook/bin/record-playbook.mjs` from a scaffolded project. The helper writes deterministic structure and `要確認` defaults. Refine the step wording and role names in both files where evidence supports better names; preserve role grouping and validate after editing with `node packages/schemas/validate-playbook.mjs <project-root>/planning/playbook.json` when the repository checkout is available.
|
|
24
|
-
5.
|
|
22
|
+
3. Examine `edit.json` sources and available `analysis.json` tags or speaker observations to name placeholder roles. Use `.akari/asset-references.json` and `.akari/style-usage.json` to identify reusable parts and licenses. Keep only `builtin`, `lab`, and `ai` parts. Check used skills and recipe `requires` for required tools and skills. Derive `beats` from plan lines in `planning/script.json` when present; otherwise use chapter headings in captions or `planning/plan.md` together with visual cut boundaries. Write the `## 内訳` table in `playbook.md`.
|
|
23
|
+
4. Run `node skills/record-playbook/bin/record-playbook.mjs <project-root> --name <name> --category <category> [--license <license>]` from a repository checkout, or use `.claude/skills/record-playbook/bin/record-playbook.mjs` from a scaffolded project. The helper writes deterministic structure and `要確認` defaults. If a video exists in `exports/`, it uses `ffmpeg` and `ffprobe` from PATH to extract 3–5 representative PNG frames into `planning/playbook-preview/frames/` and records relative paths in `preview.frames`. `AKARI_FFMPEG` and `AKARI_FFPROBE` can override those commands. If no export or tool is available, or extraction fails, the helper warns and the outcome section says to record again after export. It clears old PNG frames before each extraction. You may review and replace frame choices while keeping 3–5 frames, the playbook aspect ratio, and a maximum long edge of 640 px. Refine the step wording and role names in both files where evidence supports better names; preserve role grouping and validate after editing with `node packages/schemas/validate-playbook.mjs <project-root>/planning/playbook.json` when the repository checkout is available.
|
|
24
|
+
5. Show the playbook and preview frames to the person, then run `node skills/record-playbook/bin/record-playbook.mjs <project-root> --install` if they want the reviewed files copied to their local shelf at `~/.akari/playbooks/<id>/`. This standalone install does not record again, so reviewed frame choices survive. The helper changes preview paths to `preview/frames/0N.png` in the shelf copy and replaces an existing copy only when `playbookVersion` is higher.
|
|
25
|
+
6. Summarize the flow, placeholder count, part count, and required tools on one screen.
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
7
9
|
|
|
8
10
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
9
11
|
const categories = new Set(['film', 'short-drama', 'mv', 'explainer', 'product', 'vlog', 'cm', 'education', 'clip', 'logo']);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
10
13
|
|
|
11
14
|
async function readOptional(file, fallback = {}) {
|
|
12
15
|
try { return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
@@ -35,7 +38,7 @@ async function validateOutput(book, markdown) {
|
|
|
35
38
|
if (book.version !== 0 || book.kind !== 'akari-playbook' || !book.name || !categories.has(book.category)) errors.push('必須項目または category が不正です');
|
|
36
39
|
if (book.placeholders.some(item => ['own', 'site'].includes(item.sample?.source))) errors.push('own / site の見本は使えません');
|
|
37
40
|
if (book.parts.some(item => ['own', 'site'].includes(item.source))) errors.push('own / site の部品は使えません');
|
|
38
|
-
for (const heading of ['目的とできあがり', '流れ', '設定値', '必要な素材(仮枠)', '使った部品', '要る道具', 'ライセンス', '出どころ']) {
|
|
41
|
+
for (const heading of ['目的とできあがり', '流れ', '設定値', '必要な素材(仮枠)', ...(book.beats ? ['内訳'] : []), '使った部品', '要る道具', 'ライセンス', '出どころ']) {
|
|
39
42
|
if (!markdown.includes(`## ${heading}`)) errors.push(`見出しがありません: ${heading}`);
|
|
40
43
|
}
|
|
41
44
|
return { ok: errors.length === 0, errors, warnings: [] };
|
|
@@ -163,13 +166,208 @@ function settingsFromEdit(edit) {
|
|
|
163
166
|
...(Object.keys(gains).length ? { audio: { roles: gains } } : {}) };
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
function collectBeats(edit, analysis, script, planMarkdown, captions, placeholders) {
|
|
170
|
+
const fps = edit.output?.fps || 30;
|
|
171
|
+
const available = new Set(placeholders.map(item => item.id));
|
|
172
|
+
const visualTracks = asArray(edit.tracks).filter(track => track.lane === 'visual' && track.id !== 'playbook-placeholders-audio');
|
|
173
|
+
const mainTrack = visualTracks.find(track => track.id === 'playbook-placeholders')
|
|
174
|
+
?? visualTracks.reduce((best, track) => {
|
|
175
|
+
const mediaCount = candidate => asArray(candidate.items).filter(item => item.source?.kind === 'media' && item.source?.src).length;
|
|
176
|
+
return !best || mediaCount(track) > mediaCount(best) ? track : best;
|
|
177
|
+
}, null);
|
|
178
|
+
const visualCuts = asArray(mainTrack?.items).filter(item => Number.isFinite(item.at) && Number.isFinite(item.duration) && item.duration > 0)
|
|
179
|
+
.sort((a, b) => a.at - b.at || String(a.id).localeCompare(String(b.id)));
|
|
180
|
+
const end = Math.max(0, ...visualCuts.map(item => (item.at + item.duration) / fps));
|
|
181
|
+
const plans = asArray(script?.lines).filter(line => line?.kind === 'plan' && Number.isFinite(line.at) && line.at >= 0 && typeof line.text === 'string' && line.text.trim())
|
|
182
|
+
.sort((a, b) => a.at - b.at || String(a.id).localeCompare(String(b.id)));
|
|
183
|
+
const used = new Set();
|
|
184
|
+
const uniqueId = (value) => {
|
|
185
|
+
const base = slug(value);
|
|
186
|
+
let candidate = base;
|
|
187
|
+
for (let n = 2; used.has(candidate); n++) candidate = `${base}-${n}`;
|
|
188
|
+
used.add(candidate);
|
|
189
|
+
return candidate;
|
|
190
|
+
};
|
|
191
|
+
if (plans.length) return plans.map((line, index) => {
|
|
192
|
+
const next = plans[index + 1]?.at ?? end;
|
|
193
|
+
const [titlePart, ...noteParts] = line.text.split(/:\s*/u);
|
|
194
|
+
const title = titlePart.trim() || line.text.trim();
|
|
195
|
+
const note = noteParts.join(': ').trim();
|
|
196
|
+
return { id: uniqueId(line.beat || line.id || title), at: line.at,
|
|
197
|
+
duration: line.duration > 0 ? line.duration : Math.max(0.01, next - line.at || 5),
|
|
198
|
+
title, ...(note ? { note } : {}),
|
|
199
|
+
...(available.has(line.placeholder) ? { placeholder: line.placeholder } : {}) };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const headings = [];
|
|
203
|
+
for (const match of String(planMarkdown).matchAll(/^#{2,4}\s+(.+)$/gmu)) {
|
|
204
|
+
const timed = /^(?:(\d+):)?(\d{1,2}):(\d{2})\s*[—–-]?\s*(.+)$/u.exec(match[1].trim());
|
|
205
|
+
const at = timed ? Number(timed[1] ?? 0) * 3600 + Number(timed[2]) * 60 + Number(timed[3]) : undefined;
|
|
206
|
+
headings.push({ at, timed: Boolean(timed), title: (timed ? timed[4] : match[1]).trim() });
|
|
207
|
+
}
|
|
208
|
+
const captionRows = Array.isArray(captions) ? captions : asArray(captions?.captions);
|
|
209
|
+
for (const item of captionRows) {
|
|
210
|
+
const chapterText = /^(?:#{1,4}\s*|第[一二三四五六七八九十0-9]+章\s*)/u.exec(item?.text ?? '');
|
|
211
|
+
if (!['chapter', 'heading'].includes(item?.kind ?? item?.type) && !chapterText) continue;
|
|
212
|
+
const at = item.at ?? item.start;
|
|
213
|
+
const title = (item.title ?? item.text)?.replace(/^(?:#{1,4}\s*)/u, '');
|
|
214
|
+
if (Number.isFinite(at) && typeof title === 'string' && title.trim()) headings.push({ at, timed: true, title: title.trim() });
|
|
215
|
+
}
|
|
216
|
+
for (const [index, heading] of headings.filter(item => !item.timed).entries()) heading.at = visualCuts[index]?.at / fps;
|
|
217
|
+
headings.sort((a, b) => (a.at ?? Infinity) - (b.at ?? Infinity));
|
|
218
|
+
const sources = new Map(asArray(edit.sources).map(source => [source.id, source]));
|
|
219
|
+
const observations = asArray(analysis.sources ?? analysis.assets ?? analysis.observations);
|
|
220
|
+
const placeholderForCut = cut => {
|
|
221
|
+
const source = sources.get(cut.source?.src);
|
|
222
|
+
const role = source ? sourceRole(source, observations) : cut.source?.canvas?.intent;
|
|
223
|
+
return placeholders.find(item => item.role === role)?.id;
|
|
224
|
+
};
|
|
225
|
+
const timedHeadings = headings.filter(heading => heading.timed);
|
|
226
|
+
if (timedHeadings.length) {
|
|
227
|
+
const chapters = timedHeadings.filter((heading, index) => index === 0 || heading.at !== timedHeadings[index - 1].at);
|
|
228
|
+
return chapters.map((heading, index) => {
|
|
229
|
+
const at = heading.at;
|
|
230
|
+
const until = chapters[index + 1]?.at ?? Math.max(end, at + 5);
|
|
231
|
+
const overlapByRole = new Map();
|
|
232
|
+
for (const cut of visualCuts) {
|
|
233
|
+
const overlap = Math.max(0, Math.min(until, (cut.at + cut.duration) / fps) - Math.max(at, cut.at / fps));
|
|
234
|
+
const placeholder = placeholderForCut(cut);
|
|
235
|
+
if (overlap > 0 && placeholder) overlapByRole.set(placeholder, (overlapByRole.get(placeholder) ?? 0) + overlap);
|
|
236
|
+
}
|
|
237
|
+
const placeholder = [...overlapByRole].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
238
|
+
return { id: uniqueId(heading.title), at, duration: Math.max(0.01, until - at), title: heading.title,
|
|
239
|
+
...(placeholder ? { placeholder } : {}) };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (!visualCuts.length) return headings.filter(heading => Number.isFinite(heading.at)).map((heading, index, timed) => ({
|
|
243
|
+
id: uniqueId(heading.title), at: heading.at,
|
|
244
|
+
duration: Math.max(0.01, (timed[index + 1]?.at ?? end) - heading.at || 5), title: heading.title }));
|
|
245
|
+
return visualCuts.map((cut, index) => {
|
|
246
|
+
const at = cut.at / fps;
|
|
247
|
+
const heading = headings.find(item => item.at === at);
|
|
248
|
+
const placeholder = placeholderForCut(cut);
|
|
249
|
+
const role = placeholders.find(item => item.id === placeholder)?.role;
|
|
250
|
+
const title = heading?.title || cut.name || role || `カット ${index + 1}`;
|
|
251
|
+
return { id: uniqueId(cut.id || title), at, duration: cut.duration / fps, title,
|
|
252
|
+
...(placeholder ? { placeholder } : {}) };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
166
256
|
function table(rows) { return `| ${rows.map(row => row.map(value => String(value ?? '—').replaceAll('|', '\\|')).join(' | ')).join(' |\n| ')} |`; }
|
|
167
257
|
function renderMarkdown(book) {
|
|
168
258
|
const steps = book.steps.map((step, index) => `${index + 1}. **${step.title}** — ${step.what}${step.checkpoint ? ' ☑ ここで確認' : ''}`).join('\n');
|
|
169
259
|
const settings = table([['キー', '値', '意味'], ['---', '---', '---'], ...Object.entries(book.settings).map(([key, value]) => [key, JSON.stringify(value), '次のプロジェクトの初期値'])]);
|
|
170
260
|
const placeholders = table([['役割', '種類', '尺の目安', '見本'], ['---', '---', '---', '---'], ...asArray(book.placeholders).map(item => [item.role, item.kind, item.durationHintSec ?? '—', item.sample ? `${item.sample.category}/${item.sample.id}` : 'なし'])]);
|
|
261
|
+
const beats = book.beats?.length
|
|
262
|
+
? table([['時刻(秒)', '見出し', 'メモ', '仮枠'], ['---', '---', '---', '---'], ...book.beats.map(item => [`${item.at}–${item.at + item.duration}`, item.title, item.note ?? '—', item.placeholder ?? '—'])])
|
|
263
|
+
: 'なし';
|
|
171
264
|
const parts = table([['種類', '名前', '入手先', 'ライセンス'], ['---', '---', '---', '---'], ...asArray(book.parts).map(item => [item.kind, item.id, item.source, item.license])]);
|
|
172
|
-
return `# ${book.name}\n\n${book.category} の作り方。流れと仮枠を再利用できます。\n\n## 目的とできあがり\n\n${book.category}、${book.settings.aspect}、目標 ${book.settings.targetDurationSec}
|
|
265
|
+
return `# ${book.name}\n\n${book.category} の作り方。流れと仮枠を再利用できます。\n\n## 目的とできあがり\n\n${book.category}、${book.settings.aspect}、目標 ${book.settings.targetDurationSec} 秒。${book.preview?.frames?.length ? '' : '\n\n見本のコマはまだ(書き出し後に記録し直す)'}\n\n## 流れ\n\n${steps}\n\n## 設定値\n\n${settings}\n\n## 必要な素材(仮枠)\n\n${placeholders}\n\n## 内訳\n\n${beats}\n\n## 使った部品\n\n${parts}\n\n## 要る道具\n\nCLI ${book.requires.cli}。道具: ${book.requires.tools.join('、') || 'なし'}。スキル: ${book.requires.skills.join('、') || 'なし'}。無い道具は別の道具または手作業で補います。\n\n## ライセンス\n\n${book.license}(要確認)。外部由来の素材は見本を付けず仮枠にしました。\n\n## 出どころ\n\n${book.provenance.fromProject} / ${book.provenance.recordedAt} / AKARI ${book.provenance.akariVersion}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function latestExport(project) {
|
|
269
|
+
const directory = path.join(project, 'exports');
|
|
270
|
+
let files;
|
|
271
|
+
try { files = await fs.readdir(directory); }
|
|
272
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
273
|
+
const videos = await Promise.all(files.filter(file => /\.(mp4|mov|m4v|webm)$/i.test(file)).map(async file => {
|
|
274
|
+
const fullPath = path.join(directory, file);
|
|
275
|
+
const stat = await fs.stat(fullPath);
|
|
276
|
+
return stat.isFile() ? { path: fullPath, modified: stat.mtimeMs } : null;
|
|
277
|
+
}));
|
|
278
|
+
return videos.filter(Boolean).sort((a, b) => b.modified - a.modified)[0]?.path ?? null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function frameTimes(edit, outputDuration) {
|
|
282
|
+
const fps = edit.output?.fps || 30;
|
|
283
|
+
const cuts = asArray(edit.tracks).filter(track => track.lane === 'visual')
|
|
284
|
+
.flatMap(track => asArray(track.items))
|
|
285
|
+
.filter(item => item.source?.kind === 'media' && Number.isFinite(item.at) && Number.isFinite(item.duration) && item.duration > 0);
|
|
286
|
+
const editDuration = Math.max(0, ...cuts.map(item => (item.at + item.duration) / fps));
|
|
287
|
+
const count = Math.min(5, Math.max(3, cuts.length));
|
|
288
|
+
return Array.from({ length: count }, (_, index) => {
|
|
289
|
+
const start = outputDuration * index / count;
|
|
290
|
+
const end = outputDuration * (index + 1) / count;
|
|
291
|
+
const selected = cuts.map(item => ({ duration: item.duration,
|
|
292
|
+
midpoint: (item.at + item.duration / 2) / fps * outputDuration / (editDuration || outputDuration) }))
|
|
293
|
+
.filter(item => item.midpoint >= start && item.midpoint < end)
|
|
294
|
+
.sort((a, b) => b.duration - a.duration)[0];
|
|
295
|
+
return Math.min(outputDuration - 0.05, Math.max(0, selected?.midpoint ?? (start + end) / 2));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function clearPreviewFrames(directory) {
|
|
300
|
+
await fs.mkdir(directory, { recursive: true });
|
|
301
|
+
for (const file of await fs.readdir(directory)) {
|
|
302
|
+
if (file.toLowerCase().endsWith('.png')) await fs.rm(path.join(directory, file));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function createPreview(project, edit) {
|
|
307
|
+
const video = await latestExport(project);
|
|
308
|
+
if (!video) return undefined;
|
|
309
|
+
const ffmpeg = process.env.AKARI_FFMPEG ?? 'ffmpeg';
|
|
310
|
+
const ffprobe = process.env.AKARI_FFPROBE ?? 'ffprobe';
|
|
311
|
+
const directory = path.join(project, 'planning', 'playbook-preview', 'frames');
|
|
312
|
+
await clearPreviewFrames(directory);
|
|
313
|
+
try {
|
|
314
|
+
const { stdout } = await execFileAsync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', video]);
|
|
315
|
+
const duration = Number(stdout.trim());
|
|
316
|
+
if (!Number.isFinite(duration) || duration <= 0) throw new Error(`書き出しの尺を取得できません: ${video}`);
|
|
317
|
+
const width = edit.output?.width || 1920;
|
|
318
|
+
const height = edit.output?.height || 1080;
|
|
319
|
+
const frameWidth = width >= height ? 640 : Math.max(1, Math.round(640 * width / height));
|
|
320
|
+
const frameHeight = height >= width ? 640 : Math.max(1, Math.round(640 * height / width));
|
|
321
|
+
const frames = [];
|
|
322
|
+
for (const [index, at] of frameTimes(edit, duration).entries()) {
|
|
323
|
+
const filename = `${String(index + 1).padStart(2, '0')}.png`;
|
|
324
|
+
await execFileAsync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', String(at), '-i', video,
|
|
325
|
+
'-frames:v', '1', '-vf', `scale=${frameWidth}:${frameHeight}:force_original_aspect_ratio=increase,crop=${frameWidth}:${frameHeight}`,
|
|
326
|
+
path.join(directory, filename)]);
|
|
327
|
+
frames.push(`playbook-preview/frames/${filename}`);
|
|
328
|
+
}
|
|
329
|
+
return { frames };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
await clearPreviewFrames(directory);
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function installPlaybook(project, book) {
|
|
337
|
+
const destination = path.join(process.env.AKARI_HOME ?? path.join(os.homedir(), '.akari'), 'playbooks', book.id);
|
|
338
|
+
const installed = await readOptional(path.join(destination, 'playbook.json'), null);
|
|
339
|
+
if (installed && installed.playbookVersion >= book.playbookVersion) return false;
|
|
340
|
+
await fs.mkdir(destination, { recursive: true });
|
|
341
|
+
await fs.cp(path.join(project, 'planning', 'playbook.md'), path.join(destination, 'playbook.md'));
|
|
342
|
+
await fs.rm(path.join(destination, 'preview'), { recursive: true, force: true });
|
|
343
|
+
const shelfBook = structuredClone(book);
|
|
344
|
+
if (book.preview?.frames?.length) {
|
|
345
|
+
const frameDirectory = path.join(destination, 'preview', 'frames');
|
|
346
|
+
await fs.mkdir(frameDirectory, { recursive: true });
|
|
347
|
+
shelfBook.preview.frames = [];
|
|
348
|
+
for (const frame of book.preview.frames) {
|
|
349
|
+
const filename = path.basename(frame);
|
|
350
|
+
await fs.cp(path.join(project, 'planning', frame), path.join(frameDirectory, filename));
|
|
351
|
+
shelfBook.preview.frames.push(`preview/frames/${filename}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (book.preview?.cover) {
|
|
355
|
+
await fs.mkdir(path.join(destination, 'preview'), { recursive: true });
|
|
356
|
+
await fs.cp(path.join(project, 'planning', book.preview.cover), path.join(destination, 'preview', 'cover.png'));
|
|
357
|
+
shelfBook.preview.cover = 'preview/cover.png';
|
|
358
|
+
}
|
|
359
|
+
await writeAtomic(path.join(destination, 'playbook.json'), `${JSON.stringify(shelfBook, null, 2)}\n`);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function installExistingPlaybook(projectRoot) {
|
|
364
|
+
const project = path.resolve(projectRoot);
|
|
365
|
+
const planning = path.join(project, 'planning');
|
|
366
|
+
const book = JSON.parse(await fs.readFile(path.join(planning, 'playbook.json'), 'utf8'));
|
|
367
|
+
const markdown = await fs.readFile(path.join(planning, 'playbook.md'), 'utf8');
|
|
368
|
+
const validation = await validateOutput(book, markdown);
|
|
369
|
+
if (!validation.ok) throw new Error(`作り方が不正です: ${validation.errors.join('; ')}`);
|
|
370
|
+
return installPlaybook(project, book);
|
|
173
371
|
}
|
|
174
372
|
|
|
175
373
|
async function writeAtomic(file, data) {
|
|
@@ -191,7 +389,7 @@ async function planningEvidence(project) {
|
|
|
191
389
|
return docs;
|
|
192
390
|
}
|
|
193
391
|
|
|
194
|
-
export async function recordPlaybook(projectRoot, { name, category, license = 'CC0-1.0', now = new Date() }) {
|
|
392
|
+
export async function recordPlaybook(projectRoot, { name, category, license = 'CC0-1.0', now = new Date(), install = false }) {
|
|
195
393
|
if (!name || !categories.has(category)) throw new Error('name と category(定義済み語彙)が必要です');
|
|
196
394
|
const project = path.resolve(projectRoot);
|
|
197
395
|
const edit = await readOptional(path.join(project, 'edit.json'));
|
|
@@ -202,6 +400,12 @@ export async function recordPlaybook(projectRoot, { name, category, license = 'C
|
|
|
202
400
|
const prior = await readOptional(path.join(project, 'planning', 'playbook.json'), null);
|
|
203
401
|
const packageJson = await readOptional(path.join(repoRoot, 'packages', 'akari-launcher', 'package.json'));
|
|
204
402
|
const planningDocs = await planningEvidence(project);
|
|
403
|
+
const script = await readOptional(path.join(project, 'planning', 'script.json'));
|
|
404
|
+
const captions = await readOptional(path.join(project, 'captions.json'));
|
|
405
|
+
const planMarkdown = await fs.readFile(path.join(project, 'planning', 'plan.md'), 'utf8').catch(error => {
|
|
406
|
+
if (error.code === 'ENOENT') return '';
|
|
407
|
+
throw error;
|
|
408
|
+
});
|
|
205
409
|
const recordedSteps = planningDocs.flatMap(doc => asArray(doc.steps)).filter(step => step.id && step.title && step.what);
|
|
206
410
|
const steps = recordedSteps.length ? recordedSteps.map(step => ({ id: slug(step.id), title: String(step.title), what: String(step.what),
|
|
207
411
|
...(step.skill ? { skill: slug(step.skill) } : {}), checkpoint: Boolean(step.checkpoint) })) : defaultSteps(decisions);
|
|
@@ -218,6 +422,14 @@ export async function recordPlaybook(projectRoot, { name, category, license = 'C
|
|
|
218
422
|
license, provenance: { fromProject: path.basename(project), recordedAt: now.toISOString(),
|
|
219
423
|
akariVersion: packageJson.version ?? '0.0.0', recordedBy: 'record-playbook' }
|
|
220
424
|
};
|
|
425
|
+
const beats = collectBeats(edit, analysis, script, planMarkdown, captions, book.placeholders);
|
|
426
|
+
if (beats.length) book.beats = beats;
|
|
427
|
+
try {
|
|
428
|
+
book.preview = await createPreview(project, edit);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.warn(`WARN: 見本のコマを作れませんでした: ${error.message}`);
|
|
431
|
+
}
|
|
432
|
+
if (!book.preview) delete book.preview;
|
|
221
433
|
const markdown = renderMarkdown(book);
|
|
222
434
|
book.tags.tools = tools;
|
|
223
435
|
const validation = await validateOutput(book, markdown);
|
|
@@ -226,6 +438,7 @@ export async function recordPlaybook(projectRoot, { name, category, license = 'C
|
|
|
226
438
|
await fs.mkdir(planning, { recursive: true });
|
|
227
439
|
await writeAtomic(path.join(planning, 'playbook.json'), `${JSON.stringify(book, null, 2)}\n`);
|
|
228
440
|
await writeAtomic(path.join(planning, 'playbook.md'), markdown);
|
|
441
|
+
if (install) await installPlaybook(project, book);
|
|
229
442
|
return book;
|
|
230
443
|
}
|
|
231
444
|
|
|
@@ -233,15 +446,22 @@ if (process.argv[1] && await fs.realpath(path.resolve(process.argv[1])).catch(()
|
|
|
233
446
|
const args = process.argv.slice(2);
|
|
234
447
|
const project = args.shift();
|
|
235
448
|
const options = {};
|
|
236
|
-
for (let index = 0; index < args.length;
|
|
449
|
+
for (let index = 0; index < args.length;) {
|
|
450
|
+
if (args[index] === '--install') { options.install = true; index += 1; continue; }
|
|
237
451
|
if (!['--name', '--category', '--license'].includes(args[index]) || !args[index + 1]) {
|
|
238
|
-
console.error('使い方: record-playbook.mjs <project-root> --name <name> --category <category> [--license <license>]');
|
|
452
|
+
console.error('使い方: record-playbook.mjs <project-root> --name <name> --category <category> [--license <license>] [--install]');
|
|
239
453
|
process.exit(2);
|
|
240
454
|
}
|
|
241
455
|
options[args[index].slice(2)] = args[index + 1];
|
|
456
|
+
index += 2;
|
|
242
457
|
}
|
|
243
458
|
if (!project) { console.error('プロジェクトの場所が必要です'); process.exit(2); }
|
|
244
459
|
try {
|
|
460
|
+
if (options.install && !options.name && !options.category) {
|
|
461
|
+
const installed = await installExistingPlaybook(project);
|
|
462
|
+
console.log(installed ? '作り方を手元の棚へ写しました' : '手元の棚には同じ版か新しい版があります');
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
245
465
|
const result = await recordPlaybook(project, options);
|
|
246
466
|
console.log(`作り方: ${path.join(path.resolve(project), 'planning', 'playbook.md')} / 流れ ${result.steps.length}、仮枠 ${result.placeholders.length}、部品 ${result.parts.length}、要る道具 ${result.requires.tools.length}`);
|
|
247
467
|
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } 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
|
+
import { validatePlaybookFiles } from '../../../packages/schemas/validate-playbook.mjs';
|
|
8
|
+
import { recordPlaybook } from '../bin/record-playbook.mjs';
|
|
9
|
+
|
|
10
|
+
const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
11
|
+
const fixture = join(repo, 'packages', 'project-scaffold', 'test', 'fixtures', 'finished-project-sample');
|
|
12
|
+
|
|
13
|
+
test('record-playbook prefers planned lines and falls back to cut boundaries and plan headings', async () => {
|
|
14
|
+
const scratch = await mkdtemp(join(tmpdir(), 'akari-record-beats-'));
|
|
15
|
+
try {
|
|
16
|
+
const withScript = join(scratch, 'script');
|
|
17
|
+
await cp(fixture, withScript, { recursive: true });
|
|
18
|
+
await mkdir(join(withScript, 'planning'), { recursive: true });
|
|
19
|
+
await writeFile(join(withScript, 'planning', 'script.json'), JSON.stringify({ version: 0, kind: 'akari-script', lines: [
|
|
20
|
+
{ id: 'm-1', kind: 'memo', at: 1, text: 'メモ' },
|
|
21
|
+
{ id: 'p-intro', kind: 'plan', at: 0, duration: 10, text: '導入: 結論', beat: 'intro' },
|
|
22
|
+
{ id: 'p-body', kind: 'plan', at: 10, duration: 15, text: '本題', beat: 'body' }
|
|
23
|
+
] }));
|
|
24
|
+
const fromScript = await recordPlaybook(withScript, { name: '台本あり', category: 'explainer' });
|
|
25
|
+
assert.deepEqual(fromScript.beats.map(beat => [beat.id, beat.at, beat.duration, beat.title, beat.note]), [
|
|
26
|
+
['intro', 0, 10, '導入', '結論'], ['body', 10, 15, '本題', undefined]
|
|
27
|
+
]);
|
|
28
|
+
assert.match(await readFile(join(withScript, 'planning', 'playbook.md'), 'utf8'), /## 必要な素材(仮枠)[\s\S]*## 内訳[\s\S]*## 使った部品/);
|
|
29
|
+
assert.equal((await validatePlaybookFiles(join(withScript, 'planning', 'playbook.json'))).ok, true);
|
|
30
|
+
|
|
31
|
+
const withoutScript = join(scratch, 'cuts');
|
|
32
|
+
await cp(fixture, withoutScript, { recursive: true });
|
|
33
|
+
await mkdir(join(withoutScript, 'planning'), { recursive: true });
|
|
34
|
+
await writeFile(join(withoutScript, 'planning', 'plan.md'), '## 00:00 導入\n\n## 00:10 本題\n');
|
|
35
|
+
const fromCuts = await recordPlaybook(withoutScript, { name: 'カットから', category: 'explainer' });
|
|
36
|
+
assert.deepEqual(fromCuts.beats.map(beat => [beat.at, beat.duration, beat.title]), [
|
|
37
|
+
[0, 10, '導入'], [10, 15, '本題']
|
|
38
|
+
]);
|
|
39
|
+
assert.equal((await validatePlaybookFiles(join(withoutScript, 'planning', 'playbook.json'))).ok, true);
|
|
40
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('overlay tracks do not add cut-based beats', async () => {
|
|
44
|
+
const scratch = await mkdtemp(join(tmpdir(), 'akari-record-overlays-'));
|
|
45
|
+
try {
|
|
46
|
+
const project = join(scratch, 'finished');
|
|
47
|
+
await cp(fixture, project, { recursive: true });
|
|
48
|
+
const editPath = join(project, 'edit.json');
|
|
49
|
+
const edit = JSON.parse(await readFile(editPath, 'utf8'));
|
|
50
|
+
edit.tracks.unshift({ id: 'overlays', lane: 'visual', items: [
|
|
51
|
+
{ id: 'o1', at: 0, duration: 100, source: { kind: 'media', src: 'talk-a' } },
|
|
52
|
+
{ id: 'o2', at: 100, duration: 100, source: { kind: 'group' } },
|
|
53
|
+
{ id: 'o3', at: 200, duration: 100, source: { kind: 'group' } }
|
|
54
|
+
] });
|
|
55
|
+
await writeFile(editPath, JSON.stringify(edit));
|
|
56
|
+
const book = await recordPlaybook(project, { name: '重ねあり', category: 'explainer' });
|
|
57
|
+
assert.deepEqual(book.beats.map(beat => [beat.at, beat.duration]), [[0, 10], [10, 15]]);
|
|
58
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('timed chapter headings combine cuts and use the longest visible role', async () => {
|
|
62
|
+
const scratch = await mkdtemp(join(tmpdir(), 'akari-record-chapters-'));
|
|
63
|
+
try {
|
|
64
|
+
const project = join(scratch, 'finished');
|
|
65
|
+
await cp(fixture, project, { recursive: true });
|
|
66
|
+
await mkdir(join(project, 'planning'), { recursive: true });
|
|
67
|
+
const editPath = join(project, 'edit.json');
|
|
68
|
+
const edit = JSON.parse(await readFile(editPath, 'utf8'));
|
|
69
|
+
edit.sources.push({ id: 'screen', path: 'assets/screen.mp4' });
|
|
70
|
+
edit.tracks.find(track => track.id === 'v1').items = [
|
|
71
|
+
{ id: 'a', at: 0, duration: 150, source: { kind: 'media', src: 'talk-a' } },
|
|
72
|
+
{ id: 'b', at: 150, duration: 150, source: { kind: 'media', src: 'screen' } },
|
|
73
|
+
{ id: 'c', at: 300, duration: 150, source: { kind: 'media', src: 'screen' } },
|
|
74
|
+
{ id: 'd', at: 450, duration: 300, source: { kind: 'media', src: 'talk-b' } }
|
|
75
|
+
];
|
|
76
|
+
await writeFile(editPath, JSON.stringify(edit));
|
|
77
|
+
const analysisPath = join(project, 'analysis.json');
|
|
78
|
+
const analysis = JSON.parse(await readFile(analysisPath, 'utf8'));
|
|
79
|
+
analysis.sources.push({ sourceId: 'screen', role: '画面収録', kind: 'video', source: 'own' });
|
|
80
|
+
await writeFile(analysisPath, JSON.stringify(analysis));
|
|
81
|
+
await writeFile(join(project, 'planning', 'plan.md'), '## 00:00 導入\n\n## 00:12 本題\n');
|
|
82
|
+
const book = await recordPlaybook(project, { name: '章あり', category: 'explainer' });
|
|
83
|
+
const byRole = new Map(book.placeholders.map(item => [item.role, item.id]));
|
|
84
|
+
assert.deepEqual(book.beats.map(beat => [beat.at, beat.duration, beat.title, beat.placeholder]), [
|
|
85
|
+
[0, 12, '導入', byRole.get('画面収録')],
|
|
86
|
+
[12, 13, '本題', byRole.get('話している本人の寄り')]
|
|
87
|
+
]);
|
|
88
|
+
assert.equal((await validatePlaybookFiles(join(project, 'planning', 'playbook.json'))).ok, true);
|
|
89
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('record-playbook omits beats when no plan, heading, or visual cut yields one', async () => {
|
|
93
|
+
const scratch = await mkdtemp(join(tmpdir(), 'akari-record-no-beats-'));
|
|
94
|
+
try {
|
|
95
|
+
const project = join(scratch, 'finished');
|
|
96
|
+
await cp(fixture, project, { recursive: true });
|
|
97
|
+
const editPath = join(project, 'edit.json');
|
|
98
|
+
const edit = JSON.parse(await readFile(editPath, 'utf8'));
|
|
99
|
+
edit.tracks.find(track => track.id === 'v1').items = [];
|
|
100
|
+
await writeFile(editPath, JSON.stringify(edit));
|
|
101
|
+
const book = await recordPlaybook(project, { name: '内訳なし', category: 'explainer' });
|
|
102
|
+
assert.equal(Object.hasOwn(book, 'beats'), false);
|
|
103
|
+
const saved = JSON.parse(await readFile(join(project, 'planning', 'playbook.json'), 'utf8'));
|
|
104
|
+
assert.equal(Object.hasOwn(saved, 'beats'), false);
|
|
105
|
+
assert.match(await readFile(join(project, 'planning', 'playbook.md'), 'utf8'), /## 内訳\n\nなし\n\n## 使った部品/);
|
|
106
|
+
assert.equal((await validatePlaybookFiles(join(project, 'planning', 'playbook.json'))).ok, true);
|
|
107
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
108
|
+
});
|