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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.5.0-beta.2",
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/` は任意。`cover.png` か `sample.mp4` を 1 つまで。無ければ画面は文字だけで出す
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.2",
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 track = { id: 'playbook-placeholders', lane: 'visual', items: [] };
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
- const duration = Math.max(1, Math.round((placeholder.durationHintSec ?? 5) * fps));
720
- // Empty planned canvas items are valid edit-v2 nodes without a media path. A
721
- // sample is a catalog reference, never an asset copied into the project.
722
- track.items.push({
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 (track.items.length) edit.tracks.push(track);
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 placeholdersAdded = addPlaceholders(edit, playbook.placeholders ?? []);
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 eight fixed Markdown headings and the JSON schema in sync. For an existing playbook, increment `playbookVersion`. Use atomic writes.
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. Summarize the flow, placeholder count, part count, and required tools on one screen.
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} 秒。\n\n## 流れ\n\n${steps}\n\n## 設定値\n\n${settings}\n\n## 必要な素材(仮枠)\n\n${placeholders}\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`;
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; index += 2) {
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
+ });