akari-video 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/vendor/.akari-capability-sources.json +5 -0
  3. package/vendor/docs/contract-2026-07-14-edit-json-v1-crop.md +8 -0
  4. package/vendor/docs/contract-2026-07-22-render-basics.md +25 -3
  5. package/vendor/docs/contract-2026-08-02-preview-parity.md +244 -0
  6. package/vendor/docs/contract-2026-08-05-fx-v0.md +85 -0
  7. package/vendor/docs/contract-2026-08-06-direction-recipes-v0.md +234 -0
  8. package/vendor/packages/akari-launcher/package.json +1 -1
  9. package/vendor/packages/direction/README.md +52 -0
  10. package/vendor/packages/direction/bin/expand-direction.mjs +160 -0
  11. package/vendor/packages/direction/package.json +13 -0
  12. package/vendor/packages/overlay-runtime/README.md +22 -2
  13. package/vendor/packages/overlay-runtime/package.json +1 -1
  14. package/vendor/packages/schemas/asset-meta.schema.json +39 -0
  15. package/vendor/packages/schemas/bin/validate-asset.mjs +48 -3
  16. package/vendor/packages/schemas/bin/validate-edit.mjs +247 -0
  17. package/vendor/packages/schemas/edit.schema.json +122 -5
  18. package/vendor/packages/schemas/examples/edit-cuts-fx-color-overlay-missing-color-invalid/edit.json +9 -0
  19. package/vendor/packages/schemas/examples/edit-cuts-fx-intensity-out-of-range-invalid/edit.json +9 -0
  20. package/vendor/packages/schemas/examples/edit-cuts-fx-invalid-id/edit.json +9 -0
  21. package/vendor/packages/schemas/examples/edit-cuts-fx-unknown-key-invalid/edit.json +9 -0
  22. package/vendor/packages/schemas/examples/edit-cuts-fx-valid/edit.json +19 -0
  23. package/vendor/packages/schemas/examples/edit-layers-invalid-crop-out-of-bounds/edit.json +18 -0
  24. package/vendor/packages/schemas/examples/edit-layers-invalid-perspective-degenerate/edit.json +18 -0
  25. package/vendor/packages/schemas/examples/edit-layers-invalid-perspective-out-of-range/edit.json +18 -0
  26. package/vendor/packages/schemas/examples/edit-layers-invalid-perspective-wrong-corner-count/edit.json +18 -0
  27. package/vendor/packages/schemas/examples/edit-layers-valid/edit.json +2 -0
  28. package/vendor/packages/schemas/test/validate-edit.test.mjs +226 -0
  29. package/vendor/skills/overlay-authoring/3d.md +56 -0
  30. package/vendor/skills/overlay-authoring/telop.md +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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": {
@@ -30,6 +30,8 @@
30
30
  "docs/contract-2026-08-03-caption-display-encoding-qc-v1.md",
31
31
  "docs/contract-2026-08-03-cut-candidate-bridge-v1.md",
32
32
  "docs/contract-2026-08-03-status-integrity-v1.md",
33
+ "docs/contract-2026-08-05-fx-v0.md",
34
+ "docs/contract-2026-08-06-direction-recipes-v0.md",
33
35
  "packages/akari-launcher/bin/akari.mjs",
34
36
  "packages/akari-launcher/package.json",
35
37
  "packages/akari-launcher/README.md",
@@ -51,6 +53,9 @@
51
53
  "packages/decision-cards/package.json",
52
54
  "packages/decision-cards/README.md",
53
55
  "packages/decision-cards/report-helper.mjs",
56
+ "packages/direction/bin/expand-direction.mjs",
57
+ "packages/direction/package.json",
58
+ "packages/direction/README.md",
54
59
  "packages/edit-lint/bin/edit-lint.mjs",
55
60
  "packages/edit-lint/package.json",
56
61
  "packages/edit-store/package.json",
@@ -1,5 +1,13 @@
1
1
  # edit.json v1 crop(リフレーミング)契約
2
2
 
3
+ > **[2026-08-06 追記] superseded**: 本契約が定める `cuts[].crop`(`keyframes[].box` 形式・
4
+ > source 秒 / source フレーム相対)は実装されないまま、後継の `cuts[].framing.crop`
5
+ > (`{x,y,w,h}` 単一オブジェクト・出力キャンバス相対・静的。ズームは `framing.keyframes` が
6
+ > 別途担う)に置き換わった。後継の契約行は `docs/contract-2026-07-22-render-basics.md` #6、
7
+ > プレビュー実装は `docs/contract-2026-08-02-preview-parity.md` §2.4.2 を参照。
8
+ > 本文(§1〜§11)はレンダ未実装だった当時の設計記録として不変のまま残す(訂正は本追記のみで
9
+ > 行い、以降の本文は書き換えない)。
10
+
3
11
  - 日付: 2026-07-14
4
12
  - 状態: 実装ラウンドの SSOT(`cuts[].crop` フィールドのみ確定)
5
13
  - 前提: `contract-2026-07-13-m1-m4.md`(edit.json v0 の確定契約)、
@@ -1,6 +1,6 @@
1
- # レンダー基礎機能契約(速度 / クロマキー背景置換 / 基本トランジション / LUT / 音声マスター処理)
1
+ # レンダー基礎機能契約(速度 / クロマキー背景置換 / 基本トランジション / LUT / 音声マスター処理 / 画角操作 / フリーズ)
2
2
 
3
- - 日付: 2026-07-22
3
+ - 日付: 2026-07-22(2026-08-06 追記: #6 画角操作 / #7 フリーズを増築)
4
4
  - 状態: **draft**(実装と並走で approved 化)。本書は技術仕様のみ。
5
5
  判断経緯・実装レーンの運用は非公開の内部記録で管理する(本リポには置かない方針)
6
6
  - 前提: `contract-2026-07-17-data-contract-versioning.md`(三原則)、
@@ -10,7 +10,7 @@
10
10
  受け入れ条件とする(仕様先行・バックエンドの silent drop を許さない —
11
11
  schema・実装・lint・出力検証を同時に納品する)
12
12
 
13
- ## 1. スコープ(5 機能・いずれも ffmpeg 直結)
13
+ ## 1. スコープ(7 機能・いずれも ffmpeg 直結)
14
14
 
15
15
  | # | 機能 | edit.json 拡張(追記のみ) | ffmpeg 実装 | 出力検証 |
16
16
  |---|---|---|---|---|
@@ -19,6 +19,8 @@
19
19
  | 3 | 基本トランジション | `cuts[].transition_out`: {type: dissolve/fade-black/fade-white, duration} | `xfade`(transition 指定があるカット境界のみ xfade 経路) | 境界フレームの中間ブレンド実在をフレーム抽出で確認・指定なし境界はハードカット維持 |
20
20
  | 4 | 色調フィルター(LUT) | `output.look`: {lut(プリセット参照 or パス), intensity} | `lut3d`(intensity は `blend` 併用) | LUT 有無 2 出力のフレームピクセル差分・プリセット表 `presets/luts/`(初期 2〜3 本。2026-07-29 に `catalog/luts/` から移設) |
21
21
  | 5 | 音声マスター処理 | `audio.master`: {denoise(off/std/strong), loudnorm(target LUFS・既定 -14)} | `afftdn` / `loudnorm`(2 パスでなく 1 パス許容 v0) | 出力のラウドネス実測(ffmpeg ebur128)が目標 ±1LU |
22
+ | 6 | 画角操作(静的クロップ / ズームキーフレーム / 段階縮小) | `cuts[].framing`: `{crop?: {x,y,w,h}(0..1 の出力相対・静的), keyframes?: [{t,scale,cx?,cy?}](t=カット内秒・線形補間。2 点でズーム、3 点以上で段階縮小・cx/cy 省略時 0.5)}` | 出力キャンバスへフィット済みの frame を `crop` で窓抜きし `scale` で再拡大(punch-in)。静的 `crop` は `w/h/x/y` とも定数。ズームは `crop` 自身の `w/h` が実機検証で init 時一度しか評価されない制約があるため、`scale` 側を `eval=frame` で `scale(t)` 倍に広げ、`crop` は固定 `w=width:h=height` のまま `x/y` だけを `t` の関数で追わせる方式(詳細 §4-1) | 静的 crop は出力フレームの画素でクロップ位置が宣言どおりであることを実測・ズームは開始/中間/終端フレームで可視要素の実測サイズから逆算したスケールが線形補間の理論値と一致(±5%)・3 点キーフレームは 2 段階の縮小がフレーム抽出で確認できる |
23
+ | 7 | フリーズ(動画停止) | `cuts[].freeze`: `{at_sec, duration_sec}`(at_sec=カット内秒でフレーム静止・停止分だけカット尺が伸びる。コンテンツを削らない) | `trim` 分割 + `tpad`(`stop_mode=clone`)+ `concat`。カット先頭(at_sec=0)での静止は `tpad` の `start_mode=clone` が本機の ffmpeg で下流の `fps` フィルタと組み合わさると最終フレームを 1 枚欠落させるバグを実機検証で確認したため使わず、frame-index trim(`start_frame=0:end_frame=1`)で 1 フレーム種を取り出し `stop_mode=clone` で伸ばしてから先頭に concat する方式で代替(詳細 §4-2)。音声は該当区間を無音(`anullsrc`)で埋める(直前音の継続はしない・詳細 §4-3) | 静止区間内の 2 フレームが画素一致(ロスレスエンコードで実測)・出力尺 = 元尺 + duration_sec(ffprobe 実測)・静止区間の音声が無音であること(`silencedetect`/`volumedetect` 実測)を確認 |
22
24
 
23
25
  - 除外(次段送り): ブレンドモード・PinP・プリレンダ合成レール(レイヤー機構が前提のため)
24
26
 
@@ -34,3 +36,23 @@
34
36
  1. `speed` の音声ピッチ保持(atempo = ピッチ維持)を既定とするか、ピッチ変動オプションを持つか
35
37
  2. LUT 初期カタログの中身の選定
36
38
  3. xfade 移行で render-cut の concat 構造をどこまで作り替えるか(v0 = 指定境界のみ / 全面 xfade 化)
39
+
40
+ ## 4. #6/#7 実装決定(2026-08-06 追記・画角操作 + フリーズ増築)
41
+
42
+ ### 4-1. 画角(`cuts[].framing`)
43
+
44
+ - **crop と keyframes の併存**: 両方宣言された場合は `keyframes` を優先する。`crop` は「1 点ズームの縮退形」であり、両立させる意味論が無いため(複製 drift の温床にもなる)
45
+ - **scale < 1 の扱い**: `keyframes[].scale` は仕組み上「クロップ窓を縮めて拡大する」ため 1 未満(キャンバスの外まで見せる=リビール)は原理的に表現できない。レンダ側で `max(1, scale)` にクランプする(silent drop ではなく仕組み上の上限として契約に明記)
46
+ - **crop.w/h が init 一度しか評価されない**: ffmpeg の `crop` フィルタは `x`/`y` は `t` を使った毎フレーム再評価に対応するが、`w`/`h` は(この ffmpeg ビルドで)フィルタ初期化時の一度きりの評価に固定されており `eval` オプション自体が存在しない(実機検証: `t` を含む `w`/`h` 式は `crop=... w='...t...'` で `Error when evaluating the expression` を返す)。そのため実装は「`scale` を `eval=frame` で `width*scale(t) : height*scale(t)` に広げてから固定サイズ `width:height` で `crop` する」方式を採る(クロップ窓の拡大 = `scale` 側の時間関数、パン位置 = `crop` の `x`/`y` の時間関数、という役割分担)
47
+ - **`crop` の `x`/`y` は上流フレームの実サイズを見ない**: 同フィルタの `iw`/`ih` 定数は(動的サイズの上流から来ていても)negotiate 済みの固定リンクサイズを指し、最初のフレームのサイズに固定されたままになることを実機検証で確認した。そのため `crop` の `x`/`y` 式は `iw`/`ih` を参照せず、`scale` 側と同じ `scale(t)` 式をそのまま再計算する(対称的だが唯一 crop から見て正しい現在値)
48
+ - **ズーム中の左右ちらつき修正(2026-08-06 追記・オーナー実機指摘・ws:framing-zoom-flicker)**: `crop` の `x`/`y` は ffmpeg 上フレーム整数ピクセル位置でしか表現できず、連続的に変化する `scale(t)` は毎フレーム 1px 刻みの階段状に量子化される。この階段と本来の滑らかな軌跡との差が、細かい周期パターン上で左右にスナップするちらつきとして見える(チェッカーボード実測フィクスチャで確認: 元実装は連続フレーム間で 78% の確率で位置が逆方向に振れ、平滑トレンドからの残差 stdev 0.51px)。`scale` のフラグ変更(`bilinear`→`lanczos`)は無効(値の補間精度は変えるが `crop` の位置精度そのものは変えないため)。有効だったのは**スーパーサンプリング**: `cuts[].framing.keyframes` のズーム計算をキャンバス解像度の 2 倍(`SUPERSAMPLE=2`)で行ってから高品質フィルタで実解像度へ縮小する方式で、`crop` の 1px 量子化ステップが出力ピクセルの 1/2 になる分だけちらつきが縮む(同フィクスチャで stdev 0.51px→0.26px、連続フレーム逆方向率 78%→41%、いずれも約 48% 改善)。あわせて `scale`/`crop` 双方が参照する「現在の拡大後サイズ」式を偶数丸め(`trunc(x/2)*2`)に統一し、`scale` が実際に負向する整数サイズと `crop` 側の想定がフレームによって食い違う(`crop` 内部クランプが暗黙に発火する)ケースを閉じた。静的 `crop`(`framing.crop`、ズームではない一点窓抜き)は時間不変のため対象外・無変更
49
+
50
+ ### 4-2. フリーズ(`cuts[].freeze`)— ffmpeg 実装上の制約
51
+
52
+ - **`tpad` の `start_mode=clone` は使わない**: カット先頭(`at_sec=0`)での静止を素直に `tpad=start_mode=clone:start_duration=X` で実装すると、後続に(本機能の他パスも含め)`fps` フィルタが一つでも挟まると出力の**最終フレームが 1 枚欠落する**バグをこの ffmpeg ビルドで実機検証した(`stop_mode=clone` には同じ問題が無いことも確認済み)。代わりに、`split` で複製した全区間トリムの一方を `trim=start_frame=0:end_frame=1`(フレーム番号ベース・fps に依存しない)で 1 フレームへ切り、`stop_mode=clone` + `stop=<フレーム数-1>`(時間指定の `stop_duration` ではなく整数フレーム数)で伸ばしてから元の全区間へ concat する
53
+ - **フリーズ中の音声は無音挿入**(direct 音の継続やループはしない): 直前音をループさせるとループ境目でクリックノイズが乗る(PCM の非ゼロ交差での接続)のに対し、無音挿入は決定論的でグリッチが無い。narration/BGM/SFX は出力タイムライン上の絶対秒で独立に配置される既存契約(`cuts[].speed` と同じ前提)のため、freeze による尺の伸びに合わせて自動シフトはしない
54
+ - **v0 は gap-aware タイムライン(明示 `at`/`track`)との併用不可**: gap-aware パス(`computeVideoRuns`)の出力秒→ソース秒写像は速度係数のみを前提にした線形式で、フリーズによる非線形な静止区間があると破綻する。`cuts[].freeze` が宣言された状態で gap-aware 判定(`needsGapAwareCutTimeline`)が真になる場合、render-cut は明示的に例外を投げて止まる(silent drop を許さない契約の原則どおり、機能を無言で無視しない)。デフォルトの逐次タイムラインでのみ有効
55
+
56
+ ### 4-3. プレビュー乖離
57
+
58
+ 画角(`cuts[].framing`)とフリーズ(`cuts[].freeze`)はレンダ(本契約 #6/#7)のみの対応であり、Web UI / shell のプレビューは追随していない。`docs/contract-2026-08-02-preview-parity.md` の適合状況表に明示済み。
@@ -58,6 +58,235 @@
58
58
  - `t` 〜 `t + duration` の窓外では非表示。**初期状態も非表示**(窓に入るまで描画しない)
59
59
  - 表示中は `currentTime` を出力時刻に同期する
60
60
 
61
+ #### 2.4.1 空間クロップ(`layers[].crop`。2026-08-06 導入)
62
+ - `crop = { x, y, w, h }`(**0..1 正規化・ソースフレーム相対・静的**)。省略時は既定
63
+ `{x:0,y:0,w:1,h:1}`(全面 = crop 無し)で、既存プロジェクトの見た目・書き出しはバイト等価のまま
64
+ - **合成の適用順は crop → scale → perspective → rotate → opacity → overlay**(確定。
65
+ `packages/render-cut/src/layers.mjs` の合成チェーンで scale の直前に `crop=` を 1 段挿す。
66
+ 既存の chromakey → format=yuva420p の後、scale/rotate/opacity より前。`perspective`
67
+ 〔`layers[].perspective`。§2.4.4〕は scale の直後・rotate の直前に挿す — 2026-08-06 追記)
68
+ - **プレビュー実装(shell / Web 共通の考え方。2026-08-06 web-layer-placement-parity で
69
+ 中心基準へ統一済み)**: レイヤー要素を wrapper で包まず、同じ `<video>` 要素に
70
+ `clip-path: inset(...)` を掛けて視覚的に切り抜き、`transform-origin` をクロップ矩形の中心へ
71
+ 動かすことで拡縮・回転のピボットを実際の合成基準点に合わせる(crop 無しなら
72
+ `transform-origin: 50% 50%` に一致し、既存の transform 適用と完全に同じ見た目になる)。
73
+ レイヤーの位置基準は shell/Web とも「箱の中心を `(outputWidth/2+x, outputHeight/2+y)` に置く」
74
+ (箱サイズ = `videoWidth/Height × transform.scale`)で統一されており、pivot
75
+ (`transform-origin` の割合)をクロップ中心にし、`translate(-pivotX%, -pivotY%) rotate(deg)`
76
+ で該当点をアンカーへ合わせる:
77
+ - shell(`apps/shell/extensions/akari-preview`): `updateStageScale` のレイヤーループ
78
+ (`akari-preview-open-handler.ts`)が正本。従来からこの中心基準
79
+ - Web(`packages/preview-server/public/app.js` の `applyLayerLayout`): 2026-08-06 以前は
80
+ 「要素の自然な静的位置(キャンバス左上)から `translate(x,y) scale(s)`」という独自の基準
81
+ だった(オーナー実機報告: 同じ edit.json でも shell と Web で PiP の見た目の位置が違う)。
82
+ `applyLayerLayout` へ一本化し、`left/top = outputSize/2+x,y`・`width/height =
83
+ videoWidth/Height×scale`・`transform: translate(-pivot%,-pivot%) rotate(deg)` へ揃えた
84
+ (`scale()` は独立した transform 関数ではなくなり、箱サイズへ焼き込む — shell と同じ単位)。
85
+ crop 無しでは `transform-origin: 50% 50%` のまま変化しないため回帰は無い
86
+ - **直接操作**: レイヤー選択 UI に**クロップモード**(既存の移動/リサイズ/回転と排他のモード
87
+ 切替 — トグルボタン。shell/Web 双方に実装)+ 8 方向ハンドル(n/ne/e/se/s/sw/w/nw)。
88
+ ドラッグ結果は正規化座標で `layers[].crop` へ書き戻す(確定=pointerup のみ、既存の
89
+ transform ハンドルと同じ書き込み契約)。クロップモードの編集オーバーレイ(外枠=ソースフレーム
90
+ 全体・内枠=現在のクロップ窓)は**現在のクロップ矩形の中心**を pivot に描く(=上記の実際の
91
+ 合成基準点と同一。2026-08-06 crop-handle-anchor-fix 以前は「全面中心固定」の近似だったが、
92
+ 後述の錨補正と噛み合わず編集中に外枠がドリフトして見えるため統一した)
93
+ - **ハンドルは錨補正込みで書き戻す(ドラッグ辺以外は画面不動)**(2026-08-06
94
+ crop-handle-anchor-fix。オーナー実機報告: PiP の下辺だけをトリムしたいのに素材全体の位置が
95
+ 動いてしまう)。上記のとおり配置の錨点は「crop 矩形の中心」なので、`crop` だけを書き戻すと
96
+ 中心が動いて錨点自体がずれ、絵全体が画面上でシフトしてしまう。ハンドル操作は `crop` と
97
+ 同時に `transform.x/y` を補正し、`{crop, transform}` を**同一 patch**で書き戻す
98
+ (crop 単独 → transform 単独の2段書きは中間フレームで一瞬ジャンプして見えるため禁止。
99
+ ドラッグ中のライブプレビューにも同じ補正を適用する)。scale/rotate は補正の対象外(動かした
100
+ 辺以外の全ての点が画面上で不動になることを保証する補正なので、1 点だけを狙うハンドル別の
101
+ 特殊対応は不要)
102
+ - shell の補正式(`(outputWidth/2+x, outputHeight/2+y)` 錨点・`videoWidth/height` はレイヤーの
103
+ ネイティブ px): 新旧クロップ中心を `c`→`c'`(0..1 正規化)とすると
104
+ `Δ = Rot(rotate)·scale·(c'−c)·(videoWidth, videoHeight)`、`x' = x + Δx`、`y' = y + Δy`
105
+ (`rotate=0` では回転行列が恒等になり単純な軸ごとの加算に潰れる)
106
+ - Web は配置慣習が異なる(`transform-origin`=クロップ中心・`translate(x,y)` は origin 相対の
107
+ scale/rotate の**外側**で効く CSS 合成のため)ので独立導出: `T' = T + (scale·Rot(rotate) − I)·Δ`
108
+ (`scale=1` かつ `rotate=0` のときは恒等 = 補正不要。それ以外は shell と同じく必要)
109
+ - 各サーフェス独立実装・独立ユニットテスト(§2.2.1 と同じ「意図的なコード重複」方針。
110
+ shell: `src/common/layer-crop-anchor.ts` + `test/layer-crop-anchor.test.mjs`、Web:
111
+ `packages/preview-server/public/layer-crop-anchor.js` +
112
+ `packages/preview-server/test/layer-crop-anchor.test.mjs`)。render-cut は無変更(補正済みの
113
+ `crop`/`transform` を通常どおり読むだけで書き出しは自動的に一致する — crop 中心を錨点にする
114
+ 配置式は render-cut の `overlay=x=(main_w-overlay_w)/2+x:y=...` と shell が数学的に同一のため)
115
+
116
+ #### 2.4.2 画角操作(`cuts[].framing`。2026-08-06 追記)
117
+
118
+ `cuts[].framing`(静的クロップ `crop` / ズームキーフレーム `keyframes`)のプレビュー再現。
119
+ render-cut(`packages/render-cut/src/cut-framing.mjs`)は「出力キャンバスへフィット済みの
120
+ フレーム(`width x height`)を crop で窓抜きし、必要なら scale で再拡大するパンチイン」として
121
+ 実装している(`contract-2026-07-22-render-basics.md` #6 §4-1)。プレビューはこの窓抜き演算を
122
+ CSS `transform` で近似再現する。
123
+
124
+ - **座標系**: `framing.keyframes[].t` はカット内秒(速度適用後の再生秒)。両 UI とも
125
+ 「該当カットのソース時間経過 ÷ `cuts[].speed`」で毎フレーム算出する
126
+ (freeze の `at_sec` と同一の時間軸 — §2.4.3)
127
+ - **CSS 変換**: `transform-origin: 0 0` を基準に、静的 crop は
128
+ `scale(1/crop.w, 1/crop.h) translate(-crop.x%, -crop.y%)`、ズームは
129
+ `translate(-cropXFrac%, -cropYFrac%) scale(scale)`(`cropXFrac = clip(cx*scale-0.5, 0, scale-1)`、
130
+ `cy` も同様)を、対象要素(shell/Web とも「フィット済みフレーム」を表す `<video>` 本体)へ
131
+ 直接適用する。render-cut の crop→scale 演算と数値的に等価であることは実測ではなく
132
+ 幾何学的な参照点比較でユニットテストしている
133
+ (shell: `test/cut-framing-visual.test.mjs`、Web: `packages/preview-server/test/framing-visual.test.mjs`。
134
+ 各サーフェスは独立実装 — §2.2.1 と同じ「意図的なコード重複」方針)
135
+ - **crop と keyframes の併存**: render-cut と同じく keyframes を優先する
136
+ - **shell 固有の制約(既知の割り切り)**: shell の `<video>` 本体は `cuts[].transform`
137
+ (PIP 的な位置決め・既存機能)も同じ `transform` プロパティを使う。framing が有効なカットでは
138
+ `transform-origin` を `0 0` に切り替えるため、**同一カットに `cuts[].transform` と
139
+ `cuts[].framing` を両方宣言した場合、`cuts[].transform` 側の scale/rotate のピボットが
140
+ 本来の中心(50%/50%)ではなく左上(0%/0%)にずれる**(框 = 両方無し・framing のみ・
141
+ transform のみの 3 パターンは全て正確。組み合わせのみの既知差分)。Web UI は
142
+ `cuts[].transform` を `<video>` 本体に適用する機能自体が無いため、この制約は生じない
143
+ - **表示更新のタイミング**: 静的 crop は該当カットに入った時点で確定するが、ズームは再生中
144
+ 毎フレーム再計算が必要(shell: `tick()`、Web: `playbackLoop()` + `seekTo()` の双方から呼ぶ
145
+ ことでスクラブ中も追随する)
146
+ - **回帰なし**: framing 未宣言のカットは本変更前と完全に同じ transform/transformOrigin のまま
147
+ (null を返す = 呼び出し側は何もしない)
148
+
149
+ #### 2.4.3 フリーズ(`cuts[].freeze`。2026-08-06 追記・プレビュー近似の割り切りあり)
150
+
151
+ `cuts[].freeze`(`{at_sec, duration_sec}`)は render-cut ではカットの尺そのものを
152
+ `duration_sec` だけ伸ばす(`contract-2026-07-22-render-basics.md` #7)。プレビューは
153
+ **この尺の伸びを再現しない**(タイムライン表示・シークバー・経過秒は書き出しと乖離する既知の
154
+ 近似)。かわりに、再生が `at_sec`(カット内・速度適用後の再生秒)へ到達した瞬間、
155
+ `duration_sec` 分だけ **実時間で** 動画要素と音声(narration/SFX/BGM)を一時停止し、
156
+ その間 `outputTime`(タイムライン上の出力秒)を進めないことで「静止して見える」挙動だけを
157
+ 再現する:
158
+
159
+ - 一時停止の対象は動画要素(そのままフレームが止まって見える)と `previewAudio`
160
+ (shell)/ `narrationNodes`・`sfxNodes`・`AudioContext`・レイヤー動画(Web)。
161
+ render-cut 本来の「フリーズ区間は無音を挿入し、narration/BGM は独立タイムラインで継続する」
162
+ という仕様とは異なり、**プレビューでは narration/BGM も一緒に止まる**
163
+ (近似のための単純化。書き出し結果とプレビューの音の鳴り方は一致しない)
164
+ - ホールドは「該当カットで 1 回だけ」発火する(同じカットを巻き戻して再生し直すと再度発火する)。
165
+ シーク・手動一時停止はホールドを即座に打ち切る(stale なタイマーが別の位置の再生を
166
+ 誤って止めないようにするため)
167
+ - 交差判定(`shouldHold = played >= at_sec`)は shell/Web とも
168
+ `checkCutFreezeCrossing`(それぞれ `cut-freeze-visual.ts` / `framing-visual.js`)に
169
+ 切り出し済み・ユニットテスト済み。実際のホールド開始/終了(wall-clock タイマー・
170
+ 一時停止/再開の呼び分け)は各サーフェスの再生ループ内に実装
171
+ (shell: `tick()` 内 `freezeHoldUntilMs`、Web: `playbackLoop()` 内 同名変数)
172
+ - **この割り切りを採った理由**: 正確な再現には「該当カットの出力尺を `duration_sec` 伸ばし、
173
+ 以降のセグメントを後ろへずらす」タイムライン写像の変更が要る。写像の正本
174
+ (`packages/edit-store/src/timeline-map.ts` の共有カーネル)は本タスクの
175
+ 編集禁止領域(`packages/render-cut/**` 同様、preview 側から見て書き込み不可の共有基盤)に
176
+ あり、変更には別途の設計判断(gap-aware タイムラインとの整合など、
177
+ `contract-2026-07-22-render-basics.md` #7 の v0 制約と同種の考慮)を要するため、
178
+ 本ラウンドでは見送った
179
+ - **回帰なし**: freeze 未宣言のカットは本変更前と完全に同じ再生挙動のまま
180
+
181
+ #### 2.4.4 パース変形(`layers[].perspective`。2026-08-06 導入・corner-pin v0・静的)
182
+
183
+ PiP(`layers[]`)を台形変形で立体的に見せる機能。確定値は **4 隅の正規化座標
184
+ (corner-pin)を SSOT** とし、書き出し(ffmpeg `perspective`)とプレビュー(CSS
185
+ `matrix3d`、shell + Web 両面)が**同じ 4 隅**を読むことで二重実装の drift を抑える
186
+ (オーナー裁定 2026-08-06: 案 A corner-pin 採用)。**時間変化(キーフレーム)は
187
+ スコープ外**(transform 全般の共通キーフレーム設計として別途起票予定)。
188
+
189
+ - `perspective = { corners: [TL, TR, BL, BR] }`(各 `[x, y]` は **0..1 正規化・
190
+ crop 適用後の層ボックス相対・静的**)。省略時は既定なし(perspective 無し)で、
191
+ 既存プロジェクトの見た目・書き出しはバイト等価のまま。退化四角形(面積がほぼ 0)は
192
+ schema 検証で拒否する(`packages/schemas/bin/validate-edit.mjs` の
193
+ `validateLayerPerspective`、シューレース公式)
194
+ - **合成の適用順は crop → scale → perspective → rotate → opacity → overlay**
195
+ (§2.4.1 で確定済み。perspective は scale 直後・rotate 直前)
196
+
197
+ ##### ffmpeg 実装(`packages/render-cut/src/layers.mjs` + `perspective-homography.mjs`)
198
+
199
+ ffmpeg の `perspective` フィルタの `x0..y3`(`sense=destination`)は**フィルタの
200
+ 入力フレーム自身の 4 隅**がどこへ写るかを指定するパラメータであり、内側の任意矩形
201
+ (クロップ後の層ボックス)の目標 4 隅をそのまま渡しても正しい変形にならない
202
+ (実装時に誤り実装を検出・修正済み)。加えて、四隅の外側を透明にするには
203
+ **透明パディングを先に足してから内側へコーナーピンする必要がある**(パディング無しで
204
+ 直接 `perspective` を掛けると、変形四角形の外側は ffmpeg の境界クランプにより
205
+ **元の不透明色**になる — 透明にはならない。実測で確認済み)。
206
+
207
+ 実装(`layers.mjs` に 3 段: `pad,perspective,crop` を scale と rotate の間へ挿入):
208
+
209
+ 1. **pad**: 層ボックスを `PERSPECTIVE_PAD_FRAC`(= 0.5・両軸 2 倍サイズ)だけ transparent
210
+ (`black@0`)にパディングする
211
+ 2. **perspective**: Heckbert のユニット正方形→四角形射影変換(`cornersToHomography` /
212
+ `applyHomography`)で「宣言された 4 隅 → パディング後フレーム自身の 4 隅がどこへ写るか」
213
+ を計算し、その値を `x0..y3` に渡す(`sense=destination:eval=init`)。**この計算だけが
214
+ 四隅外の透明化を成立させる本質**(パディング境界のクランプサンプルが常に透明ピクセルに
215
+ 当たるようにする)
216
+ 3. **crop**: パディング分を除去し、元の層ボックスサイズへ戻す(後続の rotate/opacity/overlay
217
+ は perspective 導入前と全く同じ座標系のまま — 層ボックスの中心・サイズは不変)
218
+
219
+ 実装時に実測で判明した ffmpeg の `perspective` フィルタ固有の制約(`iw`/`ih` ではなく
220
+ `W`/`H` を使う必要がある。`iw*(-0.07)` のような「乗算記号の直後に括弧」は
221
+ "Unknown function" で拒否されるため係数を先に置く `-0.07*W` 形にする必要がある)は
222
+ `layers.mjs` のコード注釈に明記。**決定論的**(`eval=init` の静的値のみ・
223
+ `eval=frame` は本タスクのスコープ外)
224
+
225
+ ##### プレビュー実装(shell / Web 共通の考え方)
226
+
227
+ 4 隅から CSS `matrix3d(...)` を導出する純関数
228
+ `computeLayerPerspectiveVisual(perspective, boxWidthPx, boxHeightPx)` を shell/Web
229
+ それぞれが独立実装する(§2.2.1 と同じ「意図的なコード重複」方針。3 実装
230
+ 〔render-cut / shell / Web〕は同じ Heckbert 参照点でユニットテストして数値一致を担保)。
231
+
232
+ - 数学的構成: 標準(`u,v ∈ [0,1]`)ドメインの Heckbert 行列 `H`(render-cut と同一構成)を、
233
+ 「中心相対 px → 標準 `[0,1]` 小数」変換 `A` と「標準 `[0,1]` 小数 → 中心相対 px」変換 `B`
234
+ で挟んだ 3x3 行列積 `B・H・A` を CSS `matrix3d` の 4x4 へレイアウトする(**「中心化した
235
+ Heckbert を直接解く」近道は数学的に誤り** — Heckbert の導出は標準ドメイン `[0,1]` を
236
+ 前提にしており、単純に 4 隅を -0.5 して解くと異なる行列になる。実装時にユニットテスト
237
+ 1 件で検出・修正済み)
238
+ - **`matrix3d` は既存の transform 関数リストの innermost(最右)に追記する**。
239
+ `transform-origin`(クロップ矩形の中心。crop 無しならボックス自身の中心)は
240
+ リスト全体を一括で包む(`origin + M(point - origin)`)ため、matrix3d は自動的に
241
+ 「対象ボックス自身の中心相対」座標で評価される — pivot 補正の追加コードは不要
242
+ - **shell と Web で `boxWidthPx`/`boxHeightPx` の単位が異なる**(両実装とも正しい。
243
+ 各サーフェスの既存 transform 構築慣習の違いに従うだけ):
244
+ - shell(`apps/shell/extensions/akari-preview/src/common/layer-perspective-visual.ts`):
245
+ `scale` を要素の CSS `width`/`height` へ焼き込む慣習(別の `scale()` 関数を持たない)
246
+ ため、matrix3d は**スケール後の描画 px** で評価する
247
+ (`boxWidthPx = crop.w * videoWidth * scale`)
248
+ - Web(`packages/preview-server/public/layer-perspective-visual.js`):
249
+ `scale(t.scale)` が独立した transform 関数として `matrix3d` の外側(左)にあるため、
250
+ matrix3d は**ネイティブ(未スケール)px** で評価する
251
+ (`boxWidthPx = crop.w * videoWidth`、スケールは別関数が後から掛ける)
252
+ - **注入経路**: shell は `Function.prototype.toString()` でサンドボックス化された
253
+ webview へ注入(`computeCutFramingVisual` と同型のパターン。`hostAdapterScript`
254
+ 〔描画〕と `previewBootstrapScript`〔UI パネル〕の双方で独立に注入 — 別の `<script>`
255
+ ブロックで変数スコープが分離しているため)。Web は通常の ES module import
256
+ (`/layer-perspective-visual.js`)
257
+ - **回帰なし**: perspective 未宣言のレイヤーは matrix3d を一切追記しない
258
+ (`computeLayerPerspectiveVisual` が `null` を返し呼び出し側は何もしない)
259
+
260
+ ##### 直接操作(v0 の範囲)
261
+
262
+ - **プリセット(右奥/左奥/上奥/下奥)+ 角度スライダーのみ**。4 隅の直接ドラッグ
263
+ ハンドルは**次段**(本ラウンド対象外)。クロップトグルの直下に同型のトグルボタン
264
+ (shell/Web 双方)+ パネル(プリセット 4 ボタン・角度スライダー・解除ボタン)を新設
265
+ - プリセット→4 隅の展開式(shell/Web で同一・意図的なコード重複):
266
+ `compression = clamp(sin(angleDeg), 0, 0.9)` を圧縮量とし、該当辺の両端点を中点方向へ
267
+ `compression/2` だけ寄せる(例: 右奥 = `TR.y += half, BR.y -= half`)。SSOT は保存される
268
+ 4 隅の正規化座標のみ — schema には「プリセット」「角度」という概念自体は存在しない
269
+ (プレビュー UI だけが持つオーサリング時の便宜)
270
+ - 確定(書き戻し)は角度スライダーの `change`(`input` はライブプレビューのみ)/
271
+ プリセットボタンクリック / 解除ボタンで発火。**クロップモードとは排他**
272
+ (`setCropMode`/`setPerspectivePanelOpen` が互いを閉じる — ハンドル操作の衝突を避ける
273
+ ため、既存のクロップモード排他と同じ設計判断)
274
+
275
+ ##### 実測 / パリティ確認
276
+
277
+ - render-cut: 実レンダの角座標(宣言どおりの台形境界がピクセル単位で一致・±数 px)+
278
+ 四隅外の透明化(下地が透ける)を実測(`packages/render-cut/test/layers.test.mjs`)
279
+ - shell / Web: Node シミュレータ上で `transform-origin` + `matrix3d` の CSS 合成を
280
+ 再現し、render-cut と同一の Heckbert 参照点(`perspectiveReference`)と数値一致する
281
+ ことをユニットテスト(各 7〜8 件)
282
+ - Web: **実ブラウザ(Chromium/playwright)実測**で、実際に描画されたレイヤー要素の
283
+ `style.transform` に含まれる `matrix3d(...)` の値が、実測 `videoWidth`/`videoHeight`
284
+ から `computeLayerPerspectiveVisual` を独立に呼んだ参照値と一致することを確認
285
+ (`packages/preview-server/test/preview.test.mjs`。ブラウザの CSSOM 正規化
286
+ 〔カンマ後への空白挿入〕は比較前に空白除去して吸収)。shell は実機 E2E(Theia/Electron
287
+ webview の起動)を伴わないため、`tsc -b` 0 エラー + ユニットテスト + Web と同一計算式
288
+ という根拠で代替する
289
+
61
290
  ### 2.5 音声
62
291
  - **一時停止で全音声を止める**: narration / SFX の BufferSource は stop、AudioContext は suspend
63
292
  - 一時停止中のシークで音源を発火させない
@@ -94,6 +323,21 @@
94
323
  | 2.5 音声停止 | ✅(suspend + source stop 修正済み) | ✅ |
95
324
  | 2.7 lint 全経路 | ✅(PUT 一律・edit-store 共有ゲート) | ✅(Phase 2-1: 全 annotations RPC + FileService 直書き経路を writeEditSnapshot RPC 経由のゲートに統一。preview の captionWrite もゲート追加) |
96
325
  | 2.8 ペン正本 | ✅(Phase 2-2: pen-visuals.bundle.js から定数 + 描画コードを import) | ✅(正本は packages/pen-visuals へ昇格。動画面 webview は正本値の埋め込み) |
326
+ | `cuts[].framing`(2026-08-06 実装) | ✅(§2.4.2) | ✅(§2.4.2。`cuts[].transform` 併用時のみ既知の割り切りあり) |
327
+ | `cuts[].freeze`(2026-08-06 実装・近似) | 🟡(§2.4.3。静止表示のみ・尺表示は非対応) | 🟡(§2.4.3。同左) |
328
+ | `layers[].perspective`(2026-08-06 実装) | ✅(§2.4.4。実ブラウザ実測済み) | ✅(§2.4.4。tsc -b + ユニット + Web 同一計算式で担保) |
329
+
330
+ - `cuts[].framing`(静的クロップ / ズームキーフレーム)・`cuts[].freeze`(フリーズ)は
331
+ `contract-2026-07-22-render-basics.md` #6/#7 としてレンダ(render-cut)に加え、
332
+ Web UI・shell のプレビューでも表示再現した(詳細は §2.4.2/§2.4.3)。framing は
333
+ crop/zoom の見た目を CSS transform で数値的に再現、freeze は「静止して見える」挙動のみを
334
+ 実時間の一時停止で近似し、書き出しが行うタイムライン尺の伸びは再現しない
335
+ (宣言済みの割り切り。§2.4.3 に理由を明記)
336
+ - `layers[].perspective`(corner-pin パース変形)は 4 隅の正規化座標を SSOT とし、
337
+ ffmpeg `perspective` フィルタ(書き出し)と CSS `matrix3d`(shell/Web プレビュー)が
338
+ 同じ Heckbert ユニット正方形→四角形射影変換で導出される(詳細は §2.4.4)。framing/freeze
339
+ とは異なり近似ではなく数値的な再現(両サーフェスとも独立実装をユニットテストで
340
+ render-cut と同一の参照点に一致させ、Web はさらに実ブラウザで実測)
97
341
 
98
342
  ## 4. 収斂ロードマップ(正本は内部リポ)
99
343
 
@@ -0,0 +1,85 @@
1
+ # 画面 FX 小語彙 v0 契約(noise / particles / vignette / flare / color-overlay)
2
+
3
+ - 日付: 2026-08-05
4
+ - 状態: **draft**(実装と並走で approved 化)。本書は技術仕様のみ
5
+ - 前提: `contract-2026-07-22-render-basics.md`(`output.look` LUT・`cuts[].transition_out` 等の
6
+ 実装契約・検証の流儀の前例)、`contract-2026-07-17-data-contract-versioning.md`(三原則)
7
+ - 大原則: **done = 出力ファイルに現れる**。全項目、実レンダリング出力の機械検証を受け入れ条件
8
+ とする(仕様先行・バックエンドの silent drop を許さない)
9
+
10
+ ## 0. スコープ宣言
11
+
12
+ 本契約は**新規に実装した画面 FX 小語彙 5 個だけ**を対象とする。旧実装(参照実装リポ)にある
13
+ FX 479 個の移植は行わない。479 個の移植は別途中止裁定が下っており、本契約はその裁定の再訪
14
+ ではない — 需要(演出レシピが単体で成立する 4 種: ノイズ・粒子・ビネット・フレア)から見て
15
+ 必要な最小語彙だけを新規に書き起こしたものである。
16
+
17
+ ## 1. スコープ(presets/fx/ 参照表 + 5 id)
18
+
19
+ `presets/fx/`(`presets/luts/` と同じ参照表方式: `index.jsonl`)に 5 id を収める。LUT と違い
20
+ 実体ファイル(`.cube` 相当)は持たず、`id` は `packages/render-cut/src/fx.mjs` の
21
+ `FX_BUILDERS` ディスパッチ表と 1:1 対応する(実装はコードそのもの)。
22
+
23
+ | id | 機能 | 実装経路 | ツマミ |
24
+ |---|---|---|---|
25
+ | `noise` | 映像ノイズ・劣化感 | ffmpeg `noise` フィルタ直結(`all_flags=u+t` で時間変化するノイズ) | `intensity` |
26
+ | `particles` | 漂う粒子・ちり | procedural(黒キャンバス上に `geq` で複数の輝点を手続き描画し `screen` 合成) | `intensity` |
27
+ | `vignette` | 周辺減光 | ffmpeg `vignette` フィルタ(`white` 指定時は `negate,vignette,negate` の反転トリック) | `intensity` / `params.color`(`black`\|`white`、既定 `black`) |
28
+ | `flare` | 光のフレア・強調 | procedural(`particles` と同じ経路。輝点 1 個・大径・低速周回) | `intensity` |
29
+ | `color-overlay` | 画面全体への色被せ(フェード赤・カラーマット黒相当を 1 id でカバー) | ffmpeg `color=` ソース + `blend` | `intensity` / `params.color`(必須) |
30
+
31
+ ## 2. edit.json 拡張(追記のみ)
32
+
33
+ ```
34
+ cuts[].fx: [{ id, intensity?, params? }]
35
+ ```
36
+
37
+ - `id`: 上表 5 値の enum(`packages/schemas/edit.schema.json` `$defs/cutFx`)
38
+ - `intensity`: `number` `[0, 1]`。省略時 1(フル効果)。**0 は全 id 共通で恒等**
39
+ (FX 無し出力と画素等価。builder の実装に関わらず render 側が一律に no-op 化する)
40
+ - `params.color`: `vignette` は `"black"` / `"white"`(既定 `black`)。`color-overlay` は
41
+ ffmpeg の color 表記(`"red"` / `"#ff0000"` / `"0xff0000"` 等)で **必須**
42
+ (色指定なしに意味を持たないため)。`noise` / `particles` / `flare` は `params` を使わない
43
+ - 配列は**複数重ね掛け可・配列順 = 適用順**(`cuts[].transform` 等と同じ「cuts 単位の追加宣言」
44
+ という語彙上の扱い)
45
+ - 既存フィールドの意味変更はしていない。`cuts[].fx` 省略時は今日と完全に同じ出力
46
+ (非回帰: `fx` を持つカットが 1 つも無い場合、フィルタグラフの文字列は変更前と byte-for-byte
47
+ 一致する)
48
+
49
+ ## 3. 実装
50
+
51
+ - `packages/render-cut/src/fx.mjs`: 5 id のフィルタグラフビルダーと `appendCutFxChain`
52
+ (複数 fx の重ね掛けを配列順に連結し、`intensity<=0` を一律 `null`(恒等)にする共通処理)
53
+ - `packages/render-cut/src/plan.mjs`: `cuts[].transform` と同じ「cut 単位の追加処理」として
54
+ 3 つのカット結合経路(`buildCutCommand` / `buildMultiSourceCutCommand` /
55
+ `buildGapAwareCutCommand`)すべてに配線。`fx` を持つカットが 1 つでもあれば、その配列
56
+ 全体が per-cut フル WxH フレーム経路(`transform` と同じ扱い)に載る
57
+ - 決定論: `noise` の `all_seed` と `particles` / `flare` の輝点の動きは、カット位置・
58
+ fx スタック段・fx id から導いた固定ハッシュ/式のみで決まる。`Math.random` /
59
+ `Date.now` はレンダ経路のどこにも使わない
60
+
61
+ ## 4. 検証(受け入れ条件)
62
+
63
+ - L0: 既存 + 新規テストが緑(`node --test packages/render-cut/test/*.test.mjs`)・
64
+ `presets/fx/index.jsonl` が自己記述(id・kind・name・description・when_to_use・tags・
65
+ params・ai_usage・source を全エントリが持つ)かつ `fx.mjs` の `FX_IDS` /
66
+ `edit.schema.json` の `$defs/cutFx.properties.id.enum` と id 集合が完全一致・
67
+ `node --check` 全対象ファイル緑
68
+ - L1: フィクスチャ動画の実レンダで FX ごとの特徴を実測(`packages/render-cut/test/cut-fx.test.mjs`)
69
+ - 全 id 共通: `intensity=0` で FX 無し出力と画素等価 / 同一 `edit.json` の 2 回レンダが
70
+ 画素等価(決定論)
71
+ - `noise`: FX 有無の同一フレーム画素差分 > 0 かつフレーム間分散が増加
72
+ - `vignette`: 四隅の輝度の中心比が、黒指定(既定)で低下・白指定で上昇
73
+ - `color-overlay`: フレーム平均色の指定色までの距離が intensity に対して単調減少
74
+ - `particles` / `flare`: FX 有無の画素差分 > 0 かつ時間方向に変化がある(静止画でない)
75
+ - LUT との併用 1 ケース(白黒 LUT + noise)が render-cut CLI の実パイプラインを通して
76
+ 破綻しない
77
+
78
+ ## 5. 除外・既知の残作業
79
+
80
+ - `packages/edit-lint`(`packages/schemas/bin/validate-edit.mjs` とは別の、公開リポ内の
81
+ もう一つの edit.json 静的検証ツール)には本契約時点で `cuts[].fx` 専用の意味検証を追加して
82
+ いない。`additionalProperties` を拒否しない既存の緩さにより非破壊ではあるが、`transform` /
83
+ `transition_out` と同水準の検証(未知キー拒否・id enum 検証等)は未整備
84
+ - `presets/INDEX.md`(親リポジトリの棚卸し索引)に `presets/fx/` へのリンクを追加していない
85
+ - FX 479 個の移植は本契約の対象外のまま(§0 参照)