akari-video 0.1.48 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/docs/contract-2026-09-05-clip-adjust-v1.md +21 -6
- package/vendor/docs/contract-2026-09-06-cut-audio-split-v0.md +103 -0
- package/vendor/docs/contract-2026-09-06-vgpu-layer-v0.md +1 -1
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/src/edit-lint.mjs +36 -2
- package/vendor/packages/edit-store/lib/caption-display.js +2 -1
- package/vendor/packages/edit-store/lib/caption-store.d.ts +1 -0
- package/vendor/packages/edit-store/lib/caption-store.js +5 -1
- package/vendor/packages/edit-store/lib/edit-v2.d.ts +19 -0
- package/vendor/packages/edit-store/lib/edit-v2.js +31 -2
- package/vendor/packages/edit-store/lib/webview-kernel.js +29 -2
- package/vendor/packages/schemas/bin/validate-captions.mjs +5 -1
- package/vendor/packages/schemas/bin/validate-edit.mjs +34 -3
- package/vendor/packages/schemas/edit.schema.json +172 -0
- package/vendor/packages/schemas/engine-capabilities.json +1 -0
- package/vendor/packages/schemas/examples/edit-v2-adjust-fx-duplicate-invalid/edit.json +54 -0
- package/vendor/packages/schemas/examples/edit-v2-adjust-fx-range-invalid/edit.json +50 -0
- package/vendor/packages/schemas/examples/edit-v2-adjust-fx-unknown-id-invalid/edit.json +49 -0
- package/vendor/packages/schemas/examples/edit-v2-adjust-fx-valid/edit.json +66 -0
- package/vendor/packages/schemas/test/adjust-fx.test.mjs +33 -0
- package/vendor/packages/schemas/test/adjust-v1.test.mjs +6 -2
- package/vendor/packages/schemas/test/engine-capabilities.test.mjs +4 -1
- package/vendor/packages/schemas/test/fixtures/adjust-fx-cases.mjs +31 -0
- package/vendor/packages/schemas/test/textstyle-allowlist-drift.test.mjs +33 -0
- package/vendor/packages/schemas/test/validate-captions.test.mjs +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
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": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"docs/contract-2026-09-03-clip-adjust-v0.md",
|
|
65
65
|
"docs/contract-2026-09-03-preview-playback-rate-v1.md",
|
|
66
66
|
"docs/contract-2026-09-05-clip-adjust-v1.md",
|
|
67
|
+
"docs/contract-2026-09-06-cut-audio-split-v0.md",
|
|
67
68
|
"docs/contract-2026-09-06-vgpu-layer-v0.md",
|
|
68
69
|
"packages/akari-launcher/package.json",
|
|
69
70
|
"packages/akari-launcher/README.md",
|
|
@@ -7,7 +7,7 @@ date: 2026-09-05
|
|
|
7
7
|
|
|
8
8
|
## 0. 位置づけ
|
|
9
9
|
|
|
10
|
-
v0 の基本補正と LUT に curves / wheels / hue
|
|
10
|
+
v0 の基本補正と LUT に curves / wheels / hue と画素処理の fx を追加する。
|
|
11
11
|
|
|
12
12
|
## 1. 席
|
|
13
13
|
|
|
@@ -25,8 +25,10 @@ version 2 visual item の tracks[].items[].adjust。media / html / telop / filte
|
|
|
25
25
|
- `hue`: object `{ hue?, sat?, luma? }`。各値は `[{ hue: number 0..1, value: number 0..1 }]`(minItems 1・maxItems 16・
|
|
26
26
|
additionalProperties false・両方 required)。**`hue` は狭義単調増加**。中立 value = 0.5。チャンネル identity =
|
|
27
27
|
省略または全点 `|value-0.5| ≤ 1e-4`。
|
|
28
|
-
- `
|
|
29
|
-
|
|
28
|
+
- `fx`: 配列(maxItems 8)。各要素は `id` 必須・additionalProperties false。同じ id は 1 回まで。
|
|
29
|
+
配列順が適用順。空配列は書き込み側で除去し、読み取り側は未指定と同じに扱う。
|
|
30
|
+
- `sections`: `basic` / `lut` に加えて `curves` / `wheels` / `hue` / `fx`(boolean・false のときだけバイパス)。
|
|
31
|
+
- **演算順(固定)**: ① basic(v0 §3 の 1〜6)→ ② lut(trilinear・intensity 混合)→ ③ wheels → ④ curves → ⑤ hue → ⑥ fx。
|
|
30
32
|
- ③ wheels: チャンネルごとに `c = v*(1-lift)+lift` → `c = pow(max(0,c), 1/(1+gamma))` → `c *= 1+gain` → `c = clamp01(c+offset)`
|
|
31
33
|
(lift / gamma / gain / offset は当該チャンネルの値・省略 0)。
|
|
32
34
|
- ④ curves: 区分線形。点列を in 昇順に評価し、`x ≤ 最初の in` は最初の out、`x ≥ 最後の in` は最後の out、
|
|
@@ -37,7 +39,7 @@ version 2 visual item の tracks[].items[].adjust。media / html / telop / filte
|
|
|
37
39
|
(sector = floor(h'*6) の 6 分割・`c = s'v'`・`x = c(1-|((h'*6) mod 2)-1|)`・`m = v'-c`・各 clamp01)。
|
|
38
40
|
`sample(ch, h)` = 点列を hue 昇順で評価、0 点なら 0.5、1 点ならその value、範囲外は端点、間は線形。
|
|
39
41
|
**h の参照は 3 ch とも変換前の h**(旧実装どおり)。
|
|
40
|
-
- identity 判定(書き込み側の規範): basic 全 0・lut 無し・wheels 全 0・curves 全 ch identity・hue 全 ch identity
|
|
42
|
+
- identity 判定(書き込み側の規範): basic 全 0・lut 無し・wheels 全 0・curves 全 ch identity・hue 全 ch identity・fx 無しなら
|
|
41
43
|
`adjust` field 自体を除去(v0 §7 と同じ)。
|
|
42
44
|
- v1(cuts[] / layers[] 文書)には席を作らない(v0 と同じ)。
|
|
43
45
|
- ルックプリセット(D13)は語彙ではない: `presets/looks/index.jsonl`(`{id, kind:"look", name, description, when_to_use}`)+
|
|
@@ -46,6 +48,17 @@ version 2 visual item の tracks[].items[].adjust。media / html / telop / filte
|
|
|
46
48
|
|
|
47
49
|
基本補正は exposure ±3 EV、contrast / highlights / shadows / blacks / whites / temperature / tint / vibrance / saturation は ±1。全て任意、既定 0。lut は null または {lut: 空でない文字列, intensity?: 0..1}、intensity 既定 1。各 object は追加キー不可。
|
|
48
50
|
|
|
51
|
+
`fx` のパラメータはすべて任意。括弧内は既定値。
|
|
52
|
+
|
|
53
|
+
| id | パラメータ・範囲(既定値) | 意味 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `vignette` | `amount` −1..1(0.5)、`midpoint` 0..1(0.5)、`roundness` −1..1(0)、`feather` 0..1(0.5) | crop 適用後のローカル 0..1 箱で周辺減光。負の amount は明るくする。roundness は矩形寄り(−1)から縦横比を補正した円(1)へ補間。midpoint は開始位置、feather は遷移幅(0 は段差) |
|
|
56
|
+
| `blur` | `px` 0..50(8) | 半径は出力 px、`px × outputSize.x / 1920`。5×5 固定 25 タップの箱ぼかしで 1 パス近似。出力座標から source texel へ換算し、crop 内に clamp |
|
|
57
|
+
| `grain` | `amount` 0..1(0.3)、`size` 0.5..4(1) | 出力 px 単位の粒。出力フレーム番号と `floor(outputPixel / size)` を整数 hash に渡す決定論ノイズ。RGB 共通に ±amount×0.15 を加算して輝度を変える。sin による hash は使わない |
|
|
58
|
+
| `sharpen` | `amount` 0..1(0.5) | source texel の 3×3 平均との差を `rgb + amount × (rgb − average)` で加算し clamp |
|
|
59
|
+
|
|
60
|
+
旧 `cuts[].fx` は読み取り互換の語彙であり、`adjust.fx` と無関係。エンジンは従来どおり無視する。
|
|
61
|
+
|
|
49
62
|
## 3. 基本補正の数値契約
|
|
50
63
|
|
|
51
64
|
演算は video-space、すなわち gamma 符号化 sRGB 値のまま行う。scene-linear へ変換しない。
|
|
@@ -79,7 +92,9 @@ version 2 visual item の tracks[].items[].adjust。media / html / telop / filte
|
|
|
79
92
|
|
|
80
93
|
## 4. 解決順とバイパス
|
|
81
94
|
|
|
82
|
-
basic → lut → wheels → curves → hue → item 合成 → output.look
|
|
95
|
+
basic → lut → wheels → curves → hue → ⑥ fx(配列順・mask / opacity / blend の前)→ item 合成 → output.look。①〜⑤の LUT を適用する `applyAdjust` の直後に fx を評価する。空間処理は LUT に bake せず、cut A/B と layer の shader 内で行う。後続効果の近傍サンプルにも先行効果を適用する。追加 FBO は使わず、fx 無し・空配列・sections.fx=false は RGB を変更せず返す。
|
|
96
|
+
|
|
97
|
+
LUT はスラッシュなしなら presets/luts/<id>/<id>.cube、ありならプロジェクト相対(Windows / POSIX の区切り対応)。sections は false のときだけ該当段をバイパスする。OFF はプレビューと書き出しの両方でバイパスし、保存値は保持してよい。
|
|
83
98
|
|
|
84
99
|
33³、R fastest → G → B、各成分 toFixed(6) の後 Float32 に格納、適用時 trilinear。正規化は範囲 clamp、点列昇順ソート、省略既定の補完。検証器は不正な点列を修復せず拒否する。補間 span < 1e-9 では左点を使用。curves identity は旧実装どおり各差の絶対値 < 1e-5、hue identity は ≤ 1e-4。basic の演算上 identity 許容は 1e-6。
|
|
85
100
|
|
|
@@ -99,4 +114,4 @@ Schema は閉じた構造・型・範囲・点数・必須キーを検査。vali
|
|
|
99
114
|
|
|
100
115
|
## 8. 非スコープ
|
|
101
116
|
|
|
102
|
-
|
|
117
|
+
スプリット比較、エフェクト UI、CSS 近似、render-cut(ffmpeg 経路)、第 2 群の効果(グロー / 明瞭度 / かすみ除去 / ノイズ除去 / モーションブラー)、旧 cuts[].fx の実装、LUT ライブラリ。
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
**本編(cut)の映像と音声の分離 v0 — 設計草案**
|
|
2
|
+
|
|
3
|
+
状態: 草案・司令塔 / オーナー未裁定
|
|
4
|
+
|
|
5
|
+
基準: `3e878921464e1d5c73938346325818d3d5185247`(2026-09-06)。§1 はこの版の実装を読んだ事実、§2 以降は未実装の提案である。本票の成果物は本書と日英索引へのリンクだけで、コード・スキーマ・テストは変更しない。
|
|
6
|
+
|
|
7
|
+
## 1. 現状の模型(事実)
|
|
8
|
+
|
|
9
|
+
**本編音声の永続的な所有者は visual lane の media item である。** 独立した audio lane の item は BGM・ナレーション・SE 用に存在するが、cut を置くとその音声が自動でそちらへ分かれる構造ではない。以下の `path:line` はすべて上記コミットのリポジトリ相対パスであり、提案フィールドの実装根拠ではない。
|
|
10
|
+
|
|
11
|
+
| 領域 | 現状と根拠(3e878921) |
|
|
12
|
+
|---|---|
|
|
13
|
+
| cut の音声 | `packages/schemas/edit.schema.json:1312` の `itemSourceMediaV2` が素材参照、素材内秒の in/out、speed、freeze とともに `gain_db` / `mute` を持つ。`packages/schemas/edit.schema.json:1330` は埋め込み音声を speech と呼び、省略時は 0 dB・鳴ると定義する。独立音声へのリンクはない。 |
|
|
14
|
+
| visual item の境界 | `packages/schemas/edit.schema.json:1446` の `itemV2Media` は `additionalProperties: false`。`packages/schemas/edit.schema.json:1683` の `itemV2` は visual 用の閉じた oneOf。現行に item 直下の `audio` はない。`packages/edit-store/src/edit-v2.ts:203` の `ItemV2Base` と `packages/edit-store/src/edit-v2.ts:226` の `MediaItemV2` にもない。 |
|
|
15
|
+
| 独立音声の語彙 | `packages/schemas/edit.schema.json:1645` の `itemV2AudioMedia` は別の閉じた型で、role は sfx / narration / bgm、省略時 sfx。item に gain_db、fade_in/out、ducking、音量 keyframes、denoise、lowcut_hz を持つ。`packages/edit-store/src/edit-v2.ts:93` の音声 source に speed / pitch_semitones / formant がある。汎用の `envelope` / `fx` キーがあるという意味ではなく、これらの宣言からエンベロープや FX 処理を組み立てる。 |
|
|
16
|
+
| 音声 fixture | `packages/schemas/examples/edit-v2-audio-track-valid/edit.json:14` は SE の gain/fade と duration:0、同ファイルの `:30` は narration、`:42` は bgm/ducking を示す。duration:0 は未解決尺のセンチネルで、無音や分離済みを意味しない。 |
|
|
17
|
+
| トラックミュート | `packages/schemas/edit.schema.json:1705` で lane に応じ item の型を分け、`packages/schemas/edit.schema.json:1750` で visual の muted は cut の埋め込み音声、audio の muted は音声 item をプレビュー・書き出しから除外すると明記する。映像自体を非表示にする意味ではない。 |
|
|
18
|
+
| exact keys と直列化 | `packages/edit-store/src/edit-v2.ts:355` の ITEM_KEYS / AUDIO_ITEM_KEYS、`:516` の音声検証、`:600` の visual 検証、`:642` の media source 検証も未知キーを拒否する。`packages/edit-store/src/canonical.ts:9` は既定のキー順、`:138` は生成キー集合も使った item 直列化。`packages/edit-store/src/generated/edit-v2-keys.ts:4` も更新対象になるため、型だけ足す変更では足りない。 |
|
|
19
|
+
| migrate / normalize | `packages/edit-store/src/migrate/index.ts:253` は v0/v1 cut を visual の media source に移し、音声 item の対は作らない。`:393` から SE、`:434` から narration、`:480` から bgm を別途移す。`:554` の移行記録も追加音源の tracks 化である。`:724` 以降の normalize は既存 v2 を複製して正規化し、`:766` で serializeEdit する。明示 normalize は整形を変え得るので「全入力が再直列化しても元バイトと一致」とは言えない。 |
|
|
20
|
+
| 内部表現と互換ビュー | `packages/edit-store/src/internal-model.ts:1249` は source.gain_db / mute を互換 cut へ持ち越す。`:1392` の projectLegacyEdit は tracks から旧配列を作り、`:1433` で visual track の muted を cut.mute に反映する。`packages/edit-store/src/legacy-audio-view.ts:19` は独立音声 item だけを sfx/narration/bgm に射影し、audio track の muted を除外する。cut 音声はこの三つの配列に含まれない。 |
|
|
21
|
+
| frame-engine preview | `packages/edit-store/src/audio-schedule.ts:492` の projectSpeechDeclarations が cut から speech 宣言を作り、`:512` で cut.mute を除外、`:532` 以降で freeze 前後を分ける。`packages/frame-engine/src/audio/preview-audio-supply.ts:368` は speech を mutedCutTracks / allCutsMuted、それ以外を mutedAudioTracks / allAudioMuted に振り分ける。`:1692` の setMutedTracks が cuts / audio / allCuts / allAudio を受け、`:1711` で稼働中 GainNode にも反映する。ここでいう cuts はミュートする段番号の集合で、cut 宣言の配列ではない。 |
|
|
22
|
+
| render-cut の射影 | `packages/render-cut/src/internal-render.mjs:48` で mutedVisualItemIds / mutedAudioItemIds を別に集める。`:74` はミュートした独立音声を除外し、`:85` は visual cut に mute:true を付ける。cut 自体は映像用に残す。`packages/render-cut/src/plan.mjs:988` は素材に音声があり cut.mute が true でない場合のみ取り込み、`:1007` はそれ以外を尺付き無音にする。ギャップ対応側にも `packages/render-cut/src/plan.mjs:1245` の同種の分岐がある。 |
|
|
23
|
+
| GPU の音声入口 | `packages/render-cut/src/render-cut.mjs:380` で cut_audio 計画を実行し、`:403` / `:408` の audioSourcePath を GPU/OSR 共通オプションに渡す。`packages/gpu-export/src/index.mjs:125` は渡された本編音声を mux し、未指定なら映像のみ。低レベル GPU は edit.json の音声を自前で再ミックスしない。 |
|
|
24
|
+
| OSR の音声入口 | `packages/osr-export/src/index.mjs:63` は PNG 出力なら audio.wav へ、`:74` は通常出力なら渡された audioSourcePath を mux する。`:270` の muxSourceAudio は音声ストリームの有無を調べる。GPU と OSR の下流入口を直すだけでは cut の所有権は変わらない。 |
|
|
25
|
+
| 書き出しの共通順序 | `packages/render-cut/src/plan.mjs:78` は本編音声の生成、`:105` は合成後の映像/音声を入力にする追加音源ミックスを計画する。実行も `packages/render-cut/src/render-cut.mjs:478` で GPU/OSR の合成後に audio_mix を行う。したがって「全音源のミックスを GPU/OSR の前に完了する」とは異なる。4 経路のうち書き出し三経路はこの共有処理を利用する。 |
|
|
26
|
+
| preview-server | `packages/preview-server/src/frame-engine-client.ts:140` は配信された audio.speech があればそれを使い、なければ normalizedCuts から projectSpeechDeclarations を呼ぶ。`:646` で独立音声 declarations と speech を createPreviewAudioSupply に渡す。`:850` は更新時にも speech を再供給する。サーバー側の `packages/preview-server/src/preview-audio-summary.mjs:47` にも cut → speech の射影がある。初回・更新・フォールバックのすべてが対象になる。 |
|
|
27
|
+
| タイムラインの派生行 | `apps/shell/extensions/akari-annotations/src/common/derive-timeline-tracks.ts:63` の withAudioDisplaySupplement は **BGM があり、audio 行がない場合だけ** `t-audio-implied` を表示用に足す。本編の埋め込み音声を全 cut から派生する行ではない。ここをそのまま「本編音声」と読み替えると BGM と衝突する。 |
|
|
28
|
+
| 見出し | `apps/shell/extensions/akari-annotations/src/common/track-header-controls.ts:4` は video/overlay/layer に目とスピーカー、audio にスピーカー、caption/beat に目を出す。`apps/shell/extensions/akari-annotations/src/browser/akari-annotations-widget.ts:7935` は cuts 行の目とミュートを別々に配線する。この基準版のボタン集合に鍵の追加はまだ含まれず、別票 timeline-track-lock と接続する必要がある。 |
|
|
29
|
+
| 波形の二系統 | `apps/shell/extensions/akari-annotations/src/browser/akari-annotations-widget.ts:8984` は cut の src/in/out で波形を引き、`:10434` は cut チップ下部に白い波形を描く。一方、独立音声は `:10140` の updateAudioClipWaveform、`:10268` の audioLoudnessBucketColors で gain/keyframes/fade を使った波形、`:10300` でその canvas の除去を行う。独立音声用の関数だけ変えても cut 下の波形は移動しない。 |
|
|
30
|
+
| インスペクター | `apps/shell/extensions/akari-annotations/src/browser/akari-inspector-widget.ts:687` は cut 選択時の audio セクションに「埋め込み音声」の gain と mute を出す。独立音声は `:1490` の audio、`:1494` の audio:fades、`:1570` の audio:keyframes、`:1607` の audio:pitch-time、`:1635` の audio:enhancement を持つ。cut 選択だけで独立音声と同じ編集機能が出るわけではない。 |
|
|
31
|
+
| lint | `packages/edit-lint/src/edit-lint.mjs:194` は内部モデルから音声を射影して検査入力を作る。`:1243` の audio lane 分岐で gain、エンベロープ、source/item の FX、fade、in/out を検査し、`:1302` で audio の duration:0 と visual の正尺を区別する。cut の source.gain_db / mute の型・範囲は前述の schema / edit-store reader が守る。現行には A/V link の参照整合・二重供給を判定する規則はない。 |
|
|
32
|
+
|
|
33
|
+
内部資料として `akari-video-internal` の `planning/notes-2026-08-18-timeline-latency-and-track-model.md` §5 / §8 / §9、および `planning/notes-2026-09-02-audio-feature-inventory.md` を読み取り参照した。前者からは tracks を正本にし、出力時刻はフレーム・素材時刻は秒、出どころを source に閉じ込める原則を引き継ぐ。後者からは音量エンベロープのクリップ所有と、NLE → 音声編集 → DAW の段階導入を引き継ぐ。これらの過去時点の「未実装」一覧は、上表の現在の音声 keyframes / FX / muted の実装状況で補正する。内部 notes の内容・参照実装のコードは移植しない。
|
|
34
|
+
|
|
35
|
+
外部の操作参照では、Premiere はリンクを一単位として扱い、Clip > Unlink で独立させる([Adobe 公式: Link audio and video clips](https://helpx.adobe.com/premiere/desktop/add-audio-effects/basic-audio-editing/link-audio-and-video-clips.html))。Alt/Option で片側だけ選択・ドラッグする操作は [Adobe 公式リファレンスの linked clips 節](https://helpx.adobe.com/pdf/cs6/premiere_pro_reference.pdf)にある。Resolve も同録 A/V を自動リンクし、別に置いた音を Link Clips で関連付けられる([Blackmagic Design 公式ガイド: Linking Clips](https://documents.blackmagicdesign.com/UserManuals/DaVinci-Resolve-15-Definitive-Guide.pdf)、検索で取得できた該当節を確認。PDF 全文の取得はできていない)。これらは操作の参考であり、AKARI のスキーマや最新他製品版の全挙動を規定しない。
|
|
36
|
+
|
|
37
|
+
## 2. 案の比較(3 案 × 8 観点)
|
|
38
|
+
|
|
39
|
+
A は表示だけの分離、B は新規 cut 挿入から実データをリンク付きで分離、C は埋め込みを既定のまま維持して「音声を分離」で B の形へ変換する案である。B/C の `audio: false` は **visual media item 直下**、`link: <cutId>` は **audio item 直下**への追加案で、現行フィールドではない。C の「音声を分離」は音声 item の生成、続く「リンク解除」は関連の削除であり、別操作とする。
|
|
40
|
+
|
|
41
|
+
| 観点 | A. 表示だけ分離 | B. 実データで分離(挿入時リンク) | C. 明示コマンドで段階分離 |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| 1. スキーマ・edit-store・migrate / normalize | 新しい音声データは不要。cut 由来の表示 ID → visual track/item ID の対応だけを持つ。exact keys / additionalProperties は不変。派生行を normalize / migrate が保存しない。 | visual の audio、audio item の link を schema / exact keys / 生成キー / canonical / internal model に追加。分離 speech と一時 mute の語彙も下記のとおり必要。新規挿入を原子的な A/V 2 item 書き込みに変更。v0→v2 と通常 normalize は自動分離しない。 | B と同じ読取語彙。挿入・migrate・normalize の既定出力は現状維持。分離コマンドだけが追加キーと音声 item を書く。既存の埋め込みと分離済みを同じ v2 文書で混在可能にする。 |
|
|
44
|
+
| 2. エンジン(4 経路 + preview-server) | frame-engine は現行 speech、render-cut は現行 cut_audio、GPU/OSR は現行 audioSourcePath のまま。preview-server の音声射影も同じ。派生行のスピーカーを従来の cuts ミュート先へ正しく配線する。 | frame-engine の speech 生成前と render-cut の互換射影で audio:false を尊重し、映像用 cut は残す。独立した speech は音声 item として一度だけ供給。GPU/OSR は共有 cut_audio / audio_mix の変更を受け、両 mux 出口で検証。preview-server の初回・更新・fallback とサイドカー ID も対応。 | B と同じ供給規則を全経路へ先行導入してからコマンドを公開。未分離は現行供給、分離済みは独立供給。新しい入力を黙って旧経路で再生しない。 |
|
|
45
|
+
| 3. タイムライン UX | 映像行は目 + 鍵、visual track ごとに「本編音声」派生行を出しスピーカーを移す。波形は派生行へ移動。派生チップの移動・トリム・削除は元 cut に作用し独立操作不可。映像 keyframes は cut に追従し、独立した音量 keyframes は新設しない。 | 新規 cut の映像行は目 + 鍵、実音声行はスピーカー。既定はリンク移動・トリム・削除、Alt は片側、リンク解除後は独立。映像 keyframes は映像、音量 keyframes と波形は実音声 item が所有。旧 cut には A 型の互換派生行が必要。 | 未分離 cut は A 型の派生行、分離済みは B 型の実音声行。「音声を分離」で移動先を明示し一度だけ置換。未分離は cut 追従、分離後はリンク操作・Alt・解除。波形を両行に二重表示しない。鍵はリンク相手にも適用して原子的に拒否する。 |
|
|
46
|
+
| 4. インスペクター | 派生チップの選択は cut の埋め込み音量・mute に案内する。表示名を「本編音声」としても書込先は source。音声 item 専用の FX/fade/keyframes が使えると誤表示しない。 | visual 側は分離状態と「音声を選択」を表示、audio 側で gain/fade/ducking/keyframes/FX を編集。source.mute と独立音声の mute を二重に操作させない。リンク有無と同期差を表示する。 | 未分離は従来の埋め込みセクション + 分離コマンド、分離後は B と同じ。未対応の cut は理由を出しコマンドを無効化する。 |
|
|
47
|
+
| 5. lint | 新しい link 規則は不要。派生表示は lint 入力に混ぜない。見出し名と書込先の対応は UI 検証で担保する。 | 型に加え参照先の実在・visual media 限定・自己/循環/重複リンク・audio:false の整合を検証。分離 speech の role、mute、素材時間、二重供給、同期差、キーフレーム尺を検査。意図した片側編集は同期差の warning とし禁止しない。 | B の規則は追加フィールド使用時に適用。未分離の旧文書へ「分離すべき」警告を増やさない。コマンド前後の供給同値と、再実行で音声 item を増やさないことを検証する。 |
|
|
48
|
+
| 6. 既存 edit.json の互換 | 読込・表示・再生・書出しで元ファイルを一切書き換えない。スピーカー操作時だけ従来の mute 変更。独立した J/L カットは実現しない。 | 旧ファイルは未分離として変換不要。新規挿入した対と明示移行だけ変化。旧アプリは追加キーを拒否し得るため、分離後ファイルの旧版読取互換までは約束しない。 | 分離しない限りファイルのバイト不変。明示した cut と受け皿の音声 track だけ変更し、無関係な要素・別ファイルは保持。解除や音声削除で埋め込み音声を勝手に復活させない。 |
|
|
49
|
+
| 7. 工数(実装票見積り) | 3 票: 派生模型/操作、見出し/波形/検査画面、回帰検証。track-lock 票を除く。後に独立編集へ進める場合は B/C 相当が追加で必要。 | 5 票以上: 語彙、供給、挿入/リンク操作、UI、経路横断検証。全 cut を既定分離するには freeze/transition/速度/入れ子を含む一般変換が必要で、追加 1〜2 票を見込む。 | 5 票: §4 の語彙、供給、明示変換/リンク操作、互換 UI、経路横断検証。初期の変換対象を限定する見積り。一般変換や既定 B 化は後続 1〜2 票。 |
|
|
50
|
+
| 8. リスク | 実データの所有と画面の行が違い、音だけ動かせると誤解しやすい。複数 visual track を一つのスピーカーへ束ねると既存の個別 mute を失う。長期的には二重の編集模型が残る。 | 全挿入経路の更新漏れ、二重再生、旧版拒否、cut の freeze/クロスフェードを単純コピーして音を変える危険。リンクを永続時刻の親子関係にすると Alt 編集を保存できない。 | 二種類の所有模型がしばらく共存する。移行境界で muted・speech のダッキング鍵・preview 更新が欠落しやすい。一方、失敗範囲を利用者が選んだ cut に限定できる。 |
|
|
51
|
+
|
|
52
|
+
**B/C 共通の提案語彙と不変条件。** `version: 2` の追加のみの進化として、`MediaItemV2.audio?: false`(省略 = 従来の埋め込みを供給)と `AudioMediaItemV2.link?: string` を追加する。audio を共通 ItemV2Base 全体に無条件で許さず visual media に限定する。`link` は同じ edit 内の永続 cut ID を指す編集上の関連であり、レンダー時に音声 source や at をリンク先から再計算する命令ではない。初期は一対一とし、audio item は自身の src / in / out / at / duration を正本にする。
|
|
53
|
+
|
|
54
|
+
分離後も dialogue を SE と誤分類しないため、audio role に `speech` を追加する案を含める。speech はダッキングの鍵としての意味、lane/所有 track はミュート先としての意味を担い、現行の「kind が speech なら cuts ミュート」という結合をほどく。役割を narration に代用すると narration ∪ transcript の鍵や auto-level の既定が変わり得る。解除後も role:speech は残す。現在の sfx/narration/bgm の列挙と legacy view は明示的に拡張し、未知 role を sfx へ黙って落とさない。
|
|
55
|
+
|
|
56
|
+
さらに `AudioMediaItemV2.mute?: boolean`(省略 = false)を追加する案とする。元 cut の source.mute はこの item mute に移し、元 visual track の muted は分離先の専用 audio track の muted に引き継ぐ。-60 dB を無音の代用にしない。分離後の可聴条件は「audio item が mute でない、かつ所有 audio track が muted でない」であり、元 visual track の muted は実音声には作用しない。元 cut の source.gain_db / mute は明示分離時に移して削除する。audio:false と残留 source 音量が手書きで併存した場合は効かない宣言として lint で通知する。
|
|
57
|
+
|
|
58
|
+
分離は、一つの編集トランザクションで新しい audio ID の確保、音声 item 作成、cut.audio:false、リンク設定を行う。素材ファイルはコピーせず同じ sources[].id を使い、書込み前の競合・ロック検証に失敗したら全体を取り消す。リンク解除は link だけを消し、source・時刻・音量・audio:false は保つ。リンクした通常削除は両 item、Alt で音声だけ削除なら cut.audio:false を残す。映像だけ削除なら存続音声の link を除去し孤立参照を残さない。「分離済み」判定は link の存在だけに依存させず、解除後の再分離で音を複製しない。
|
|
59
|
+
|
|
60
|
+
lint は link がある音声から参照先の audio:false を要求するが、逆向きの存在義務は置かない。audio:false だけ残る映像、link を持たない role:speech 音声は、削除・解除後の正当な状態である。同じ sources[].id を再利用するだけでは二重供給と判定せず、リンクされた対の埋め込み有効状態と実際の供給 ID を調べる。
|
|
61
|
+
|
|
62
|
+
## 3. 司令塔の推奨と理由
|
|
63
|
+
|
|
64
|
+
**推奨は C(明示分離)を実データ模型とし、未分離 cut の表示には A の互換派生行を使う。** これは司令塔への提案であり裁定済みではない。C 単独で映像行のスピーカーだけを消すと既存案件の音声入口を失うため、互換派生行までを初期導入に含める。A だけでは音だけの移動・音量 keyframes を持てず、B を既定にすると未検証の一般変換をすべての挿入に強制する。
|
|
65
|
+
|
|
66
|
+
目標へは次の順で移行する。
|
|
67
|
+
|
|
68
|
+
1. schema / reader / lint に追加語彙を用意し、旧文書の意味とバイトを保つ。供給対応前は分離書込を公開しない。手書きした新語彙も旧実行経路に流さないよう、edit-store の実行用読込境界で未対応機能エラーにする。検証用の構造読取とは分ける。
|
|
69
|
+
2. 4 経路と preview-server へ同じ所有規則を導入する。埋め込み側は audio:false のときゼロ回、独立側は一回だけ供給する。映像 item はタイムライン・字幕射影・合成に残す。分離 speech を audio item の時刻・所有トラックで鳴らし、ダッキング鍵はその出力位置へ射影する。リンク解除や音だけ移動したあと、元 cut 位置を speech 鍵として二重採用しない。
|
|
70
|
+
3. opt-in の「音声を分離」とリンク編集を出す。初期対象は **トップレベルの、anchor なし・入れ子なし・speed が省略または 1・freeze なし・前後に音声クロスフェードを伴う transition なしの cut**、かつ素材に音声があると確認できたものに限る。duration はフレーム丸めを含め素材区間と整合するもの。静止画・無音素材は対象外、未検出は結果が出るまで書込み保留。未対応対象は元のまま再生・書出しを続けられ、黙って近似変換しない。
|
|
71
|
+
4. visual track ごとに「本編音声(V1)」等の派生行を音声グループへ出し、そこに未分離 cut の波形とスピーカーを置く。ID は元 track ID から安定導出し、既存の BGM 用 t-audio-implied と別にする。派生行のスピーカーは元 visual track.muted に書き、実音声行のスピーカーは audio track.muted に書く。分離済み分は派生チップを消して実音声行にだけ描く。最後に映像系見出しを目 + 鍵、字幕を目 + 鍵、音声をスピーカーに統一する。M/S・ミキサーは後続とする。
|
|
72
|
+
|
|
73
|
+
派生行は edit.json の tracks に保存せず、z 順も変えない。分離先は元 visual track ごとの専用実音声 track を必要時に作る案とし、既存 BGM/SE の muted を変更しない。以前作った受け皿の muted が元 visual track と異なる場合は同じ mute 状態の別行を用意し、既存音声の状態を上書きしない。派生行が空になったらその表示だけを消す。
|
|
74
|
+
|
|
75
|
+
リンク中の通常移動は両側の at に同じ整数フレーム差を足す。トリムは各側の素材範囲と速度から境界を計算し、両方で成立する差だけを原子的に適用する。Alt で片側をずらしても link は維持し、その後の通常移動は現在の同期差を保つ。再読込で時刻を揃え直さない。初期対応範囲内で同期差は at と source.in から導出して表示できる。将来の速度変更は両側同時に扱えるまで拒否する。
|
|
76
|
+
|
|
77
|
+
映像の transform 等の keyframes は映像に残す。音量 keyframes は音声 item 内のローカル整数フレームを正本とし、移動では t 不変、先頭トリムでは t を境界差だけ移して範囲外を除き、新境界値を既存補間規則で補う。最低点数を満たさない場合は等価な一定 gain へ明示的に畳む。片側 keyframe の編集を相手へ複写しない。いずれかの変更対象 item/track が locked なら通常のリンク操作は全体を拒否し、Alt 操作でも実際に変える側のロックを回避しない。Undo/Redo は対とキー変更を一単位で戻す。
|
|
78
|
+
|
|
79
|
+
**互換性の範囲。** 分離しない既存 edit.json は、開く・表示・再生・書き出し・新機能非使用の処理によって書換えない。既存の正規直列化 fixture は新旧 serializer で同じバイト列を返す。非正規な空白・キー順を明示 normalize した場合まで原文一致とはしないが、今回の語彙追加に伴う default キーの補完や自動分離は禁止する。v0/v1 の既存移行手順は変更せず、現行が読めない旧形式の読取対応拡大を本票で約束しない。明示分離時は対象 cut と受け皿だけを局所編集し、無関係な item・sources の内容と順序、captions.json、motion ファイル、素材を変えない。分離後のファイルを旧アプリで開けることは別の互換条件である。
|
|
80
|
+
|
|
81
|
+
## 4. 段取り(実装契約の草案・5 票)
|
|
82
|
+
|
|
83
|
+
以下は今後起票する単位であり、本設計票では実施しない。所有欄のディレクトリはその票の候補境界を示し、起票時に既存の関連ファイル・テストと必要な新規ファイルへ絞る。同じファイルを使う票は依存順に編集し、特に UI と edit-store の変更を重ねて走らせない。別票 `2026-09-06-timeline-track-lock` の成果を第4票の前提にする。
|
|
84
|
+
|
|
85
|
+
| 票 | goal | 所有ファイル(候補) | 受け入れ条件の骨子 | 依存順・main へ入る条件 |
|
|
86
|
+
|---|---|---|---|---|
|
|
87
|
+
| 1. 分離語彙と読取境界 | schema + edit-store + lint に追加のみの席を作る。未対応エンジンへ新形を渡さない。 | `packages/schemas/edit.schema.json`、同 examples の専用 fixture、`packages/edit-store/src/edit-v2.ts` / `generated/edit-v2-keys.ts` / `canonical.ts` / `internal-model.ts` / `migrate/index.ts`、`packages/edit-lint/src/edit-lint.mjs`、各パッケージの関連テスト。 | audio:false / link / role:speech / item mute の型、media 限定、リンク整合を schema と reader で一致させる。旧正規 fixture の直列化バイト一致、未操作ファイル不変、migrate の既定出力不変。新形は検証可能だが実行用読込は明示エラー。変換・挿入 UI はまだ公開しない。 | 依存なし。エンジン未変更でも旧文書をそのまま扱え、新形を黙って誤再生しないので単独で main に入れられる。 |
|
|
88
|
+
| 2. 所有規則を全供給経路へ | 埋め込み停止と独立 speech の供給を揃える。 | `packages/edit-store/src/internal-model.ts` / `legacy-audio-view.ts` / `audio-schedule.ts` と関連音声射影、`packages/frame-engine/src/audio/`、`packages/render-cut/src/internal-render.mjs` / `plan.mjs` / `render-cut.mjs`、`packages/gpu-export/src/index.mjs` / `packages/osr-export/src/index.mjs` の入口と関連テスト、`packages/preview-server/src/frame-engine-client.ts` / `preview-audio-summary.mjs` と配信の関連ファイル。必要な生成バンドルは正規生成。 | 未分離は既存結果維持、分離済みは音が一回だけ鳴る。visual muted は埋め込みだけ、audio muted/item mute は所有音声だけ停止。preview 初回/更新/fallback、通常/ギャップ cut_audio、GPU/OSR mux 後の audio_mix まで fixture を通す。speech の鍵・素材参照・ID とサイドカー再利用が分離後に正しい。 | 第1票後。全消費経路を同一リリースで対応させてから実行用拒否を解除する。どれか未対応なら拒否を残し、変換 UI は未公開のまま。 |
|
|
89
|
+
| 3. 明示変換とリンク編集 | 対象を限定して音声分離・解除・片側編集を原子的に実現する。 | `packages/edit-store/src/` の mutation/API・対応テスト、`apps/shell/extensions/akari-annotations/src/common/` の edit-store/選択/操作契約、同 `src/node/` の書込サービス、同 `src/browser/akari-annotations-widget.ts` / `akari-inspector-widget.ts` と関連テスト。 | §3 の対象判定、同一 source 参照、音量/mute 移送、audio:false、安定 ID、局所差分、冪等性。通常/Alt の移動・トリム・削除、解除後の独立性、ロック/競合拒否、Undo/Redo、再読込を確認。拒否対象にはファイル差分ゼロ。分離後のインスペクターから音量・fade・keyframes・FX を所有音声へ書ける。 | 第2票後。既定挿入は変更しない。第4票以前は現行見出しを保ってよく、分離済み音声は実音声行で操作できること。 |
|
|
90
|
+
| 4. 互換派生行と目 + 鍵 | 旧 cut の音声操作を維持したまま見出しと波形を整理する。 | `apps/shell/extensions/akari-annotations/src/common/derive-timeline-tracks.ts` / `track-header-controls.ts` / 関連レイアウト、同 `src/browser/akari-annotations-widget.ts` / `akari-inspector-widget.ts` / 関連 CSS・テスト、ミュート通知の既存配線。 | 未分離だけの案件、全分離、混在、複数 visual track、BGM 併存で正しい行とスピーカーを表示。派生行は無書戻し、元 track に個別 mute が届く。cut 下の波形を除き、派生/実音声に一回表示。目 + 鍵 / スピーカー / 字幕の目 + 鍵を実機確認し、鍵は画素・可聴性を変えない。 | 第3票と timeline-track-lock 後。BGM 用 implied 行は維持。派生行を出す変更と映像スピーカーを消す変更を一緒に入れる。 |
|
|
91
|
+
| 5. 横断検収と導入範囲の固定 | 旧案件の無変換互換と新旧混在の経路同値を検証し、初期提供範囲を固定する。 | `packages/frame-engine/test/`、`packages/render-cut/test/`、`packages/gpu-export/test/`、`packages/osr-export/test/`、`packages/preview-server/test/`、`apps/shell/extensions/akari-annotations/` の既存検証領域、利用者向け導線文書と関連契約。 | 同じ合成素材を未分離/分離/解除/片側移動/音声削除で全4経路へ渡す。音声の開始/終了、無音区間、gain、二重再生なし、speech ダッキングを比較。旧 fixture の変更前後一致、ファイル hash 不変、初期/ホット更新/再読込を確認する。GPU/OSR は実出力まで検証し、plan の一致だけで代用しない。 | 第4票後。第1〜4票の局所試験に加える導入検収。未実測経路を対応済みとしない。既定 B 化・一般変換の可否はこの結果を基に別裁定へ渡す。 |
|
|
92
|
+
|
|
93
|
+
第1票は L0、第2〜5票は各タスクが扱う実行経路に応じ L0 + L1 を要求する。横断検収ではマスター処理を切ったテスト素材で供給回数と区間 gain を比較し、時間境界は 1 出力フレーム以内、同区間の RMS 差は 0.1 dB 以内を初期の受け入れ値案とする。二重供給は区間測定に加えて宣言 ID の列挙回数でも検出する。既存 DSP/codec の差を「ビット一致」とは呼ばず、マスター有効時は既存の音声 QC 条件も適用する。しきい値の調整は測定結果を根拠として実装票に明記する。
|
|
94
|
+
|
|
95
|
+
freeze・transition・速度変更・入れ子/anchor の一般変換は5票の対象外であり、対応するまで明示分離コマンドが拒否する。特に freeze の無音区間と前後音声を表すには複数音声 item や追加の時間写像が必要になり、一対一 link の拡張を先に決める必要がある。単に in/out と duration をコピーして対象拡大しない。
|
|
96
|
+
|
|
97
|
+
## 5. 未決事項(オーナー裁定・5 点以内)
|
|
98
|
+
|
|
99
|
+
- C + 互換派生行を初期提供とし、実測後に新規挿入の既定を B に切り替える方針でよいか。
|
|
100
|
+
- 初期の「音声を分離」を§3の単純 cut に限定してよいか。freeze / transition / 速度変更 / 入れ子の一般変換はどの順に後続化するか。
|
|
101
|
+
- 分離 speech の role と audio item mute の追加、一対一 link、Alt による同期差の保持、音声削除時に埋め込みを復活させない規則を採るか。
|
|
102
|
+
- 派生行名を「本編音声(V1)」、実音声の受け皿を元 visual track ごとの専用行とするか。並びと名称変更の入口をどう見せるか。
|
|
103
|
+
- 自動移行は行わない前提のまま、将来 opt-in の「選択 cut を一括分離」を提供するか。既存案件の一括変換は個別コマンドの検収後とするか。
|
|
@@ -107,7 +107,7 @@ fn akari_uv(pos: vec4f) -> vec2f { return pos.xy / vec2f(akari.pad.x, akari.pad.
|
|
|
107
107
|
|
|
108
108
|
previewScale は辺あたり倍率で、未指定 `1`。推奨値は `1` / `0.5` / `0.25` だが、
|
|
109
109
|
実装は有限数 `0 < s <= 1` を受け付ける。範囲外は警告 1 回で `1` にする。
|
|
110
|
-
preview-server の既定は `
|
|
110
|
+
preview-server の既定は `0.5`。ズームポップアップの 3 択(1 / 0.5 / 0.25)で手動選択でき、`akari-preview-settings.vgpuPreviewScale` に保存する。範囲外は 0.5。書き出しは previewScale を省略して常に `1`(stateful は §7 の fps を渡す)。
|
|
111
111
|
canvas の CSS は absolute / inset 0 / width・height 100% / display block に固定する。
|
|
112
112
|
内部画素だけが減り、位置・大きさ・時刻・ツマミは変わらない。描画寸法は丸め、最小 1 px。
|
|
113
113
|
container が 0 サイズなら前回寸法を使い、前回もなければ描画しない。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
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": [
|
|
@@ -1598,7 +1598,7 @@ function validateAdjust(value, findings, path) {
|
|
|
1598
1598
|
});
|
|
1599
1599
|
}
|
|
1600
1600
|
};
|
|
1601
|
-
reportUnknownKeys(value, new Set(["basic", "lut", "sections", "curves", "wheels", "hue"]), path);
|
|
1601
|
+
reportUnknownKeys(value, new Set(["basic", "lut", "sections", "curves", "wheels", "hue", "fx"]), path);
|
|
1602
1602
|
|
|
1603
1603
|
if (Object.hasOwn(value, "basic")) {
|
|
1604
1604
|
const basicPath = `${path}.basic`;
|
|
@@ -1660,7 +1660,7 @@ function validateAdjust(value, findings, path) {
|
|
|
1660
1660
|
severity: "error", check: "adjust.sections.structure", message: "sections must be an object", path: sectionsPath,
|
|
1661
1661
|
});
|
|
1662
1662
|
} else {
|
|
1663
|
-
const sectionKeys = new Set(["basic", "lut", "curves", "wheels", "hue"]);
|
|
1663
|
+
const sectionKeys = new Set(["basic", "lut", "curves", "wheels", "hue", "fx"]);
|
|
1664
1664
|
reportUnknownKeys(value.sections, sectionKeys, sectionsPath);
|
|
1665
1665
|
for (const key of sectionKeys) {
|
|
1666
1666
|
if (Object.hasOwn(value.sections, key) && typeof value.sections[key] !== "boolean") {
|
|
@@ -4676,6 +4676,9 @@ function validateTextStyleV0Fields(value, label, findings, path) {
|
|
|
4676
4676
|
&& (!isFiniteNumber(value.max_width_pct) || value.max_width_pct <= 0 || value.max_width_pct >= 100)) {
|
|
4677
4677
|
flag("max_width_pct must be a finite number within (0, 100)");
|
|
4678
4678
|
}
|
|
4679
|
+
if (has("max_characters") && (!Number.isInteger(value.max_characters) || value.max_characters <= 0)) {
|
|
4680
|
+
flag("max_characters must be an integer greater than zero");
|
|
4681
|
+
}
|
|
4679
4682
|
if (has("text_anchor") && !CAPTION_TEXT_ANCHOR_VALUES.has(value.text_anchor)) {
|
|
4680
4683
|
flag("text_anchor must be one of the nine anchor codes");
|
|
4681
4684
|
}
|
|
@@ -6441,6 +6444,37 @@ function validateAdjustV1Sections(value, findings, path) {
|
|
|
6441
6444
|
const number = (v, min, max, section, at) => {
|
|
6442
6445
|
if (!isFiniteNumber(v) || v < min || v > max) report(section, 'range', at, 'は ' + min + ' から ' + max + ' の範囲の有限数である必要があります');
|
|
6443
6446
|
};
|
|
6447
|
+
if (Object.hasOwn(value, 'fx')) {
|
|
6448
|
+
const at = path + '.fx';
|
|
6449
|
+
if (!Array.isArray(value.fx)) {
|
|
6450
|
+
report('fx', 'structure', at, 'must be an array');
|
|
6451
|
+
} else {
|
|
6452
|
+
if (value.fx.length > 8) report('fx', 'max-items', at, 'must contain at most 8 effects');
|
|
6453
|
+
const ranges = {
|
|
6454
|
+
vignette: { amount: [-1, 1], midpoint: [0, 1], roundness: [-1, 1], feather: [0, 1] },
|
|
6455
|
+
blur: { px: [0, 50] },
|
|
6456
|
+
grain: { amount: [0, 1], size: [0.5, 4] },
|
|
6457
|
+
sharpen: { amount: [0, 1] },
|
|
6458
|
+
};
|
|
6459
|
+
const seen = new Set();
|
|
6460
|
+
for (const [index, fx] of value.fx.entries()) {
|
|
6461
|
+
const fxPath = at + '[' + index + ']';
|
|
6462
|
+
if (!isRecord(fx)) { report('fx', 'structure', fxPath, 'must be an object'); continue; }
|
|
6463
|
+
if (typeof fx.id !== 'string' || !Object.hasOwn(ranges, fx.id)) {
|
|
6464
|
+
report('fx', 'id', fxPath + '.id', 'unknown effect id'); continue;
|
|
6465
|
+
}
|
|
6466
|
+
if (seen.has(fx.id)) report('fx', 'duplicate-id', fxPath + '.id', 'duplicate effect id: ' + fx.id);
|
|
6467
|
+
seen.add(fx.id);
|
|
6468
|
+
const params = ranges[fx.id];
|
|
6469
|
+
for (const key of Object.keys(fx)) {
|
|
6470
|
+
if (key !== 'id' && !Object.hasOwn(params, key)) addFinding(findings, { severity: 'error', check: 'adjust.unknown-key', path: fxPath + '.' + key, message: key + ' is not defined for ' + fx.id });
|
|
6471
|
+
}
|
|
6472
|
+
for (const [key, [min, max]] of Object.entries(params)) {
|
|
6473
|
+
if (Object.hasOwn(fx, key)) number(fx[key], min, max, 'fx', fxPath + '.' + key);
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6444
6478
|
for (const section of ['curves', 'hue']) {
|
|
6445
6479
|
if (!Object.hasOwn(value, section)) continue;
|
|
6446
6480
|
const channels = value[section], at = path + '.' + section;
|
|
@@ -30,7 +30,7 @@ exports.CAPTION_UNIT_METRIC = 'ascii-half-other-one-v1';
|
|
|
30
30
|
const CAPTION_STYLE_KEYS = new Set([
|
|
31
31
|
'color', 'size_px', 'font_weight', 'line_height', 'stroke', 'background', 'zone', 'layout',
|
|
32
32
|
'font_family', 'weight', 'italic', 'underline', 'letter_spacing_em', 'align',
|
|
33
|
-
'vertical_align', 'vertical', 'text_transform', 'max_width_pct', 'text_anchor',
|
|
33
|
+
'vertical_align', 'vertical', 'text_transform', 'max_width_pct', 'max_characters', 'text_anchor',
|
|
34
34
|
'position', 'shadow', 'glow', 'animation', 'reference_height_px'
|
|
35
35
|
]);
|
|
36
36
|
const CAPTION_STROKE_KEYS = new Set(['method', 'color', 'width_px']);
|
|
@@ -394,6 +394,7 @@ function validateTextStyleV0(value, label) {
|
|
|
394
394
|
failIf(has('max_width_pct')
|
|
395
395
|
&& (!finiteNumber(value.max_width_pct)
|
|
396
396
|
|| value.max_width_pct <= 0 || value.max_width_pct >= 100), 'max_width_pct must be a finite number within (0, 100)');
|
|
397
|
+
failIf(has('max_characters') && !positiveInteger(value.max_characters), 'max_characters must be an integer greater than zero');
|
|
397
398
|
failIf(has('text_anchor') && !CAPTION_TEXT_ANCHOR_VALUES.has(value.text_anchor), 'text_anchor must be one of the nine anchor codes');
|
|
398
399
|
if (has('position')) {
|
|
399
400
|
if (!isRecord(value.position))
|
|
@@ -700,7 +700,7 @@ function isFiniteInRange(value, min, max) {
|
|
|
700
700
|
const TEXT_STYLE_KEYS = new Set([
|
|
701
701
|
'color', 'size_px', 'reference_height_px', 'font_family', 'font_weight', 'weight', 'italic', 'underline',
|
|
702
702
|
'letter_spacing_em', 'line_height', 'align', 'vertical_align', 'vertical',
|
|
703
|
-
'text_transform', 'max_width_pct', 'text_anchor', 'position', 'shadow', 'glow',
|
|
703
|
+
'text_transform', 'max_width_pct', 'max_characters', 'text_anchor', 'position', 'shadow', 'glow',
|
|
704
704
|
'animation', 'stroke', 'background', 'zone', 'layout'
|
|
705
705
|
]);
|
|
706
706
|
const TEXT_TRANSFORM_VALUES = new Set(['upper', 'uppercase', 'lower', 'lowercase', 'title', 'capitalize', 'none']);
|
|
@@ -765,6 +765,9 @@ function normalizeTextStyle(value, onUnknownKeys) {
|
|
|
765
765
|
if (isFiniteNumber(value.max_width_pct) && value.max_width_pct > 0 && value.max_width_pct < 100) {
|
|
766
766
|
style.maxWidthPct = value.max_width_pct;
|
|
767
767
|
}
|
|
768
|
+
if (Number.isInteger(value.max_characters) && value.max_characters > 0) {
|
|
769
|
+
style.maxCharacters = value.max_characters;
|
|
770
|
+
}
|
|
768
771
|
if (typeof value.text_anchor === 'string' && TEXT_ANCHOR_VALUES.has(value.text_anchor)) {
|
|
769
772
|
style.textAnchor = value.text_anchor;
|
|
770
773
|
}
|
|
@@ -982,6 +985,7 @@ function textStyleToJson(style) {
|
|
|
982
985
|
...(style.vertical !== undefined ? { vertical: style.vertical } : {}),
|
|
983
986
|
...(style.textTransform !== undefined ? { text_transform: style.textTransform } : {}),
|
|
984
987
|
...(style.maxWidthPct !== undefined ? { max_width_pct: style.maxWidthPct } : {}),
|
|
988
|
+
...(style.maxCharacters !== undefined ? { max_characters: style.maxCharacters } : {}),
|
|
985
989
|
...(style.textAnchor !== undefined ? { text_anchor: style.textAnchor } : {}),
|
|
986
990
|
...(style.position !== undefined ? {
|
|
987
991
|
position: {
|
|
@@ -214,18 +214,37 @@ export interface AdjustHueCurvesV1 {
|
|
|
214
214
|
sat?: AdjustHuePointV1[];
|
|
215
215
|
luma?: AdjustHuePointV1[];
|
|
216
216
|
}
|
|
217
|
+
export type AdjustFxV1 = {
|
|
218
|
+
id: 'vignette';
|
|
219
|
+
amount?: number;
|
|
220
|
+
midpoint?: number;
|
|
221
|
+
roundness?: number;
|
|
222
|
+
feather?: number;
|
|
223
|
+
} | {
|
|
224
|
+
id: 'blur';
|
|
225
|
+
px?: number;
|
|
226
|
+
} | {
|
|
227
|
+
id: 'grain';
|
|
228
|
+
amount?: number;
|
|
229
|
+
size?: number;
|
|
230
|
+
} | {
|
|
231
|
+
id: 'sharpen';
|
|
232
|
+
amount?: number;
|
|
233
|
+
};
|
|
217
234
|
export interface AdjustV1 {
|
|
218
235
|
basic?: AdjustBasicV0;
|
|
219
236
|
lut?: AdjustLutV0 | null;
|
|
220
237
|
curves?: AdjustCurvesV1;
|
|
221
238
|
wheels?: AdjustWheelsV1;
|
|
222
239
|
hue?: AdjustHueCurvesV1;
|
|
240
|
+
fx?: AdjustFxV1[];
|
|
223
241
|
sections?: {
|
|
224
242
|
basic?: boolean;
|
|
225
243
|
lut?: boolean;
|
|
226
244
|
curves?: boolean;
|
|
227
245
|
wheels?: boolean;
|
|
228
246
|
hue?: boolean;
|
|
247
|
+
fx?: boolean;
|
|
229
248
|
};
|
|
230
249
|
}
|
|
231
250
|
/** @deprecated Use AdjustV1. */
|
|
@@ -468,7 +468,36 @@ function validateCrop(value, path) {
|
|
|
468
468
|
}
|
|
469
469
|
function validateAdjust(value, path) {
|
|
470
470
|
requireRecord(value, path);
|
|
471
|
-
requireExactKeys(value, new Set(['basic', 'lut', 'sections', 'curves', 'wheels', 'hue']), path);
|
|
471
|
+
requireExactKeys(value, new Set(['basic', 'lut', 'sections', 'curves', 'wheels', 'hue', 'fx']), path);
|
|
472
|
+
if (hasOwn(value, 'fx')) {
|
|
473
|
+
const fxPath = path + '.fx';
|
|
474
|
+
if (!Array.isArray(value.fx) || value.fx.length > 8) {
|
|
475
|
+
throw invalid(fxPath, 'adjust.fx.structure: must be an array of at most 8 effects');
|
|
476
|
+
}
|
|
477
|
+
const ranges = {
|
|
478
|
+
vignette: { amount: [-1, 1], midpoint: [0, 1], roundness: [-1, 1], feather: [0, 1] },
|
|
479
|
+
blur: { px: [0, 50] },
|
|
480
|
+
grain: { amount: [0, 1], size: [0.5, 4] },
|
|
481
|
+
sharpen: { amount: [0, 1] },
|
|
482
|
+
};
|
|
483
|
+
const seen = new Set();
|
|
484
|
+
for (const [index, fx] of value.fx.entries()) {
|
|
485
|
+
const at = fxPath + '[' + index + ']';
|
|
486
|
+
requireRecord(fx, at);
|
|
487
|
+
if (typeof fx.id !== 'string' || !hasOwn(ranges, fx.id)) {
|
|
488
|
+
throw invalid(at + '.id', 'adjust.fx.id: unknown effect id');
|
|
489
|
+
}
|
|
490
|
+
if (seen.has(fx.id))
|
|
491
|
+
throw invalid(at + '.id', 'adjust.fx.duplicate-id: ' + fx.id);
|
|
492
|
+
seen.add(fx.id);
|
|
493
|
+
const params = ranges[fx.id];
|
|
494
|
+
requireExactKeys(fx, new Set(['id', ...Object.keys(params)]), at);
|
|
495
|
+
for (const [key, [min, max]] of Object.entries(params)) {
|
|
496
|
+
if (hasOwn(fx, key))
|
|
497
|
+
requireRange(fx[key], min, max, at + '.' + key);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
472
501
|
for (const section of ['curves', 'hue']) {
|
|
473
502
|
if (!hasOwn(value, section))
|
|
474
503
|
continue;
|
|
@@ -532,7 +561,7 @@ function validateAdjust(value, path) {
|
|
|
532
561
|
}
|
|
533
562
|
if (hasOwn(value, 'sections')) {
|
|
534
563
|
requireRecord(value.sections, `${path}.sections`);
|
|
535
|
-
const sectionKeys = new Set(['basic', 'lut', 'curves', 'wheels', 'hue']);
|
|
564
|
+
const sectionKeys = new Set(['basic', 'lut', 'curves', 'wheels', 'hue', 'fx']);
|
|
536
565
|
requireExactKeys(value.sections, sectionKeys, `${path}.sections`);
|
|
537
566
|
for (const key of sectionKeys) {
|
|
538
567
|
if (hasOwn(value.sections, key) && typeof value.sections[key] !== 'boolean') {
|
|
@@ -2162,7 +2162,34 @@ var AkariEditKernel = (() => {
|
|
|
2162
2162
|
}
|
|
2163
2163
|
function validateAdjust(value, path) {
|
|
2164
2164
|
requireRecord(value, path);
|
|
2165
|
-
requireExactKeys(value, /* @__PURE__ */ new Set(["basic", "lut", "sections", "curves", "wheels", "hue"]), path);
|
|
2165
|
+
requireExactKeys(value, /* @__PURE__ */ new Set(["basic", "lut", "sections", "curves", "wheels", "hue", "fx"]), path);
|
|
2166
|
+
if (hasOwn(value, "fx")) {
|
|
2167
|
+
const fxPath = path + ".fx";
|
|
2168
|
+
if (!Array.isArray(value.fx) || value.fx.length > 8) {
|
|
2169
|
+
throw invalid(fxPath, "adjust.fx.structure: must be an array of at most 8 effects");
|
|
2170
|
+
}
|
|
2171
|
+
const ranges = {
|
|
2172
|
+
vignette: { amount: [-1, 1], midpoint: [0, 1], roundness: [-1, 1], feather: [0, 1] },
|
|
2173
|
+
blur: { px: [0, 50] },
|
|
2174
|
+
grain: { amount: [0, 1], size: [0.5, 4] },
|
|
2175
|
+
sharpen: { amount: [0, 1] }
|
|
2176
|
+
};
|
|
2177
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2178
|
+
for (const [index, fx] of value.fx.entries()) {
|
|
2179
|
+
const at = fxPath + "[" + index + "]";
|
|
2180
|
+
requireRecord(fx, at);
|
|
2181
|
+
if (typeof fx.id !== "string" || !hasOwn(ranges, fx.id)) {
|
|
2182
|
+
throw invalid(at + ".id", "adjust.fx.id: unknown effect id");
|
|
2183
|
+
}
|
|
2184
|
+
if (seen.has(fx.id)) throw invalid(at + ".id", "adjust.fx.duplicate-id: " + fx.id);
|
|
2185
|
+
seen.add(fx.id);
|
|
2186
|
+
const params = ranges[fx.id];
|
|
2187
|
+
requireExactKeys(fx, /* @__PURE__ */ new Set(["id", ...Object.keys(params)]), at);
|
|
2188
|
+
for (const [key, [min, max]] of Object.entries(params)) {
|
|
2189
|
+
if (hasOwn(fx, key)) requireRange(fx[key], min, max, at + "." + key);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2166
2193
|
for (const section of ["curves", "hue"]) {
|
|
2167
2194
|
if (!hasOwn(value, section)) continue;
|
|
2168
2195
|
const channels = value[section];
|
|
@@ -2229,7 +2256,7 @@ var AkariEditKernel = (() => {
|
|
|
2229
2256
|
}
|
|
2230
2257
|
if (hasOwn(value, "sections")) {
|
|
2231
2258
|
requireRecord(value.sections, `${path}.sections`);
|
|
2232
|
-
const sectionKeys = /* @__PURE__ */ new Set(["basic", "lut", "curves", "wheels", "hue"]);
|
|
2259
|
+
const sectionKeys = /* @__PURE__ */ new Set(["basic", "lut", "curves", "wheels", "hue", "fx"]);
|
|
2233
2260
|
requireExactKeys(value.sections, sectionKeys, `${path}.sections`);
|
|
2234
2261
|
for (const key of sectionKeys) {
|
|
2235
2262
|
if (hasOwn(value.sections, key) && typeof value.sections[key] !== "boolean") {
|
|
@@ -349,7 +349,7 @@ function validateTextStyle(value, label) {
|
|
|
349
349
|
const allowedKeys = new Set([
|
|
350
350
|
"color", "size_px", "font_weight", "line_height", "stroke", "background", "zone", "layout",
|
|
351
351
|
"font_family", "weight", "italic", "underline", "letter_spacing_em", "align",
|
|
352
|
-
"vertical_align", "vertical", "text_transform", "max_width_pct", "text_anchor",
|
|
352
|
+
"vertical_align", "vertical", "text_transform", "max_width_pct", "max_characters", "text_anchor",
|
|
353
353
|
"position", "shadow", "glow", "animation", "reference_height_px",
|
|
354
354
|
]);
|
|
355
355
|
for (const key of Object.keys(value)) {
|
|
@@ -415,6 +415,10 @@ function validateTextStyleV0(value, label) {
|
|
|
415
415
|
&& (!isFiniteNumber(value.max_width_pct) || value.max_width_pct <= 0 || value.max_width_pct >= 100)) {
|
|
416
416
|
fail(`${label}.max_width_pct は 0 より大きく 100 未満の有限数である必要があります`);
|
|
417
417
|
}
|
|
418
|
+
if (hasOwn(value, "max_characters")
|
|
419
|
+
&& (!Number.isInteger(value.max_characters) || value.max_characters <= 0)) {
|
|
420
|
+
fail(`${label}.max_characters は 0 より大きい整数である必要があります`);
|
|
421
|
+
}
|
|
418
422
|
if (hasOwn(value, "text_anchor") && !TEXT_ANCHOR_VALUES.has(value.text_anchor)) {
|
|
419
423
|
fail(`${label}.text_anchor は定義済みの 9 値のいずれかである必要があります`);
|
|
420
424
|
}
|
|
@@ -143,7 +143,7 @@ function validateAdjust(value, label) {
|
|
|
143
143
|
fail(`${label} は object である必要があります`);
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
|
-
const allowedKeys = new Set(["basic", "lut", "sections", "curves", "wheels", "hue"]);
|
|
146
|
+
const allowedKeys = new Set(["basic", "lut", "sections", "curves", "wheels", "hue", "fx"]);
|
|
147
147
|
for (const key of Object.keys(value)) {
|
|
148
148
|
if (!allowedKeys.has(key)) fail(`${label} に未知のキーがあります: ${key}`);
|
|
149
149
|
}
|
|
@@ -190,7 +190,7 @@ function validateAdjust(value, label) {
|
|
|
190
190
|
if (!isPlainObject(sections)) {
|
|
191
191
|
fail(`${label}.sections は object である必要があります`);
|
|
192
192
|
} else {
|
|
193
|
-
const sectionKeys = new Set(["basic", "lut", "curves", "wheels", "hue"]);
|
|
193
|
+
const sectionKeys = new Set(["basic", "lut", "curves", "wheels", "hue", "fx"]);
|
|
194
194
|
for (const key of Object.keys(sections)) {
|
|
195
195
|
if (!sectionKeys.has(key)) fail(`${label}.sections に未知のキーがあります: ${key}`);
|
|
196
196
|
}
|
|
@@ -206,7 +206,7 @@ function validateAdjust(value, label) {
|
|
|
206
206
|
// Intentional dependency-free duplicate of the closed adjustV1 structure.
|
|
207
207
|
function validateAdjustV1Sections(value, path) {
|
|
208
208
|
if (!isPlainObject(value)) return;
|
|
209
|
-
const report = (section, check, at, message) => fail(at + ' ' + message);
|
|
209
|
+
const report = (section, check, at, message) => fail(at + ' ' + (section === 'fx' ? 'adjust.fx.' + check + ': ' : '') + message);
|
|
210
210
|
const object = (v, keys, section, at) => {
|
|
211
211
|
if (!isPlainObject(v)) { report(section, 'structure', at, 'は object である必要があります'); return false; }
|
|
212
212
|
for (const key of Object.keys(v)) if (!keys.includes(key)) report(section, 'unknown-key', at + '.' + key, 'は未知のキーです');
|
|
@@ -215,6 +215,37 @@ function validateAdjustV1Sections(value, path) {
|
|
|
215
215
|
const number = (v, min, max, section, at) => {
|
|
216
216
|
if (!isFiniteNumber(v) || v < min || v > max) report(section, 'range', at, 'は ' + min + ' から ' + max + ' の範囲の有限数である必要があります');
|
|
217
217
|
};
|
|
218
|
+
if (Object.hasOwn(value, 'fx')) {
|
|
219
|
+
const at = path + '.fx';
|
|
220
|
+
if (!Array.isArray(value.fx)) {
|
|
221
|
+
report('fx', 'structure', at, 'must be an array');
|
|
222
|
+
} else {
|
|
223
|
+
if (value.fx.length > 8) report('fx', 'max-items', at, 'must contain at most 8 effects');
|
|
224
|
+
const ranges = {
|
|
225
|
+
vignette: { amount: [-1, 1], midpoint: [0, 1], roundness: [-1, 1], feather: [0, 1] },
|
|
226
|
+
blur: { px: [0, 50] },
|
|
227
|
+
grain: { amount: [0, 1], size: [0.5, 4] },
|
|
228
|
+
sharpen: { amount: [0, 1] },
|
|
229
|
+
};
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
for (const [index, fx] of value.fx.entries()) {
|
|
232
|
+
const fxPath = at + '[' + index + ']';
|
|
233
|
+
if (!isPlainObject(fx)) { report('fx', 'structure', fxPath, 'must be an object'); continue; }
|
|
234
|
+
if (typeof fx.id !== 'string' || !Object.hasOwn(ranges, fx.id)) {
|
|
235
|
+
report('fx', 'id', fxPath + '.id', 'unknown effect id'); continue;
|
|
236
|
+
}
|
|
237
|
+
if (seen.has(fx.id)) report('fx', 'duplicate-id', fxPath + '.id', 'duplicate effect id: ' + fx.id);
|
|
238
|
+
seen.add(fx.id);
|
|
239
|
+
const params = ranges[fx.id];
|
|
240
|
+
for (const key of Object.keys(fx)) {
|
|
241
|
+
if (key !== 'id' && !Object.hasOwn(params, key)) report('fx', 'unknown-key', fxPath + '.' + key, 'unknown effect parameter');
|
|
242
|
+
}
|
|
243
|
+
for (const [key, [min, max]] of Object.entries(params)) {
|
|
244
|
+
if (Object.hasOwn(fx, key)) number(fx[key], min, max, 'fx', fxPath + '.' + key);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
218
249
|
for (const section of ['curves', 'hue']) {
|
|
219
250
|
if (!Object.hasOwn(value, section)) continue;
|
|
220
251
|
const channels = value[section], at = path + '.' + section;
|
|
@@ -324,6 +324,106 @@
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
},
|
|
327
|
+
"adjustFxV1": {
|
|
328
|
+
"oneOf": [
|
|
329
|
+
{
|
|
330
|
+
"type": "object",
|
|
331
|
+
"additionalProperties": false,
|
|
332
|
+
"required": [
|
|
333
|
+
"id"
|
|
334
|
+
],
|
|
335
|
+
"properties": {
|
|
336
|
+
"id": {
|
|
337
|
+
"const": "vignette"
|
|
338
|
+
},
|
|
339
|
+
"amount": {
|
|
340
|
+
"type": "number",
|
|
341
|
+
"minimum": -1,
|
|
342
|
+
"maximum": 1,
|
|
343
|
+
"default": 0.5
|
|
344
|
+
},
|
|
345
|
+
"midpoint": {
|
|
346
|
+
"type": "number",
|
|
347
|
+
"minimum": 0,
|
|
348
|
+
"maximum": 1,
|
|
349
|
+
"default": 0.5
|
|
350
|
+
},
|
|
351
|
+
"roundness": {
|
|
352
|
+
"type": "number",
|
|
353
|
+
"minimum": -1,
|
|
354
|
+
"maximum": 1,
|
|
355
|
+
"default": 0
|
|
356
|
+
},
|
|
357
|
+
"feather": {
|
|
358
|
+
"type": "number",
|
|
359
|
+
"minimum": 0,
|
|
360
|
+
"maximum": 1,
|
|
361
|
+
"default": 0.5
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"type": "object",
|
|
367
|
+
"additionalProperties": false,
|
|
368
|
+
"required": [
|
|
369
|
+
"id"
|
|
370
|
+
],
|
|
371
|
+
"properties": {
|
|
372
|
+
"id": {
|
|
373
|
+
"const": "blur"
|
|
374
|
+
},
|
|
375
|
+
"px": {
|
|
376
|
+
"type": "number",
|
|
377
|
+
"minimum": 0,
|
|
378
|
+
"maximum": 50,
|
|
379
|
+
"default": 8
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"type": "object",
|
|
385
|
+
"additionalProperties": false,
|
|
386
|
+
"required": [
|
|
387
|
+
"id"
|
|
388
|
+
],
|
|
389
|
+
"properties": {
|
|
390
|
+
"id": {
|
|
391
|
+
"const": "grain"
|
|
392
|
+
},
|
|
393
|
+
"amount": {
|
|
394
|
+
"type": "number",
|
|
395
|
+
"minimum": 0,
|
|
396
|
+
"maximum": 1,
|
|
397
|
+
"default": 0.3
|
|
398
|
+
},
|
|
399
|
+
"size": {
|
|
400
|
+
"type": "number",
|
|
401
|
+
"minimum": 0.5,
|
|
402
|
+
"maximum": 4,
|
|
403
|
+
"default": 1
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"type": "object",
|
|
409
|
+
"additionalProperties": false,
|
|
410
|
+
"required": [
|
|
411
|
+
"id"
|
|
412
|
+
],
|
|
413
|
+
"properties": {
|
|
414
|
+
"id": {
|
|
415
|
+
"const": "sharpen"
|
|
416
|
+
},
|
|
417
|
+
"amount": {
|
|
418
|
+
"type": "number",
|
|
419
|
+
"minimum": 0,
|
|
420
|
+
"maximum": 1,
|
|
421
|
+
"default": 0.5
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
},
|
|
327
427
|
"adjustV1": {
|
|
328
428
|
"type": "object",
|
|
329
429
|
"additionalProperties": false,
|
|
@@ -359,6 +459,9 @@
|
|
|
359
459
|
},
|
|
360
460
|
"hue": {
|
|
361
461
|
"type": "boolean"
|
|
462
|
+
},
|
|
463
|
+
"fx": {
|
|
464
|
+
"type": "boolean"
|
|
362
465
|
}
|
|
363
466
|
}
|
|
364
467
|
},
|
|
@@ -370,6 +473,75 @@
|
|
|
370
473
|
},
|
|
371
474
|
"hue": {
|
|
372
475
|
"$ref": "#/$defs/adjustHueCurvesV1"
|
|
476
|
+
},
|
|
477
|
+
"fx": {
|
|
478
|
+
"type": "array",
|
|
479
|
+
"maxItems": 8,
|
|
480
|
+
"items": {
|
|
481
|
+
"$ref": "#/$defs/adjustFxV1"
|
|
482
|
+
},
|
|
483
|
+
"allOf": [
|
|
484
|
+
{
|
|
485
|
+
"contains": {
|
|
486
|
+
"type": "object",
|
|
487
|
+
"required": [
|
|
488
|
+
"id"
|
|
489
|
+
],
|
|
490
|
+
"properties": {
|
|
491
|
+
"id": {
|
|
492
|
+
"const": "vignette"
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
"minContains": 0,
|
|
497
|
+
"maxContains": 1
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
"contains": {
|
|
501
|
+
"type": "object",
|
|
502
|
+
"required": [
|
|
503
|
+
"id"
|
|
504
|
+
],
|
|
505
|
+
"properties": {
|
|
506
|
+
"id": {
|
|
507
|
+
"const": "blur"
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
"minContains": 0,
|
|
512
|
+
"maxContains": 1
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
"contains": {
|
|
516
|
+
"type": "object",
|
|
517
|
+
"required": [
|
|
518
|
+
"id"
|
|
519
|
+
],
|
|
520
|
+
"properties": {
|
|
521
|
+
"id": {
|
|
522
|
+
"const": "grain"
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
"minContains": 0,
|
|
527
|
+
"maxContains": 1
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
"contains": {
|
|
531
|
+
"type": "object",
|
|
532
|
+
"required": [
|
|
533
|
+
"id"
|
|
534
|
+
],
|
|
535
|
+
"properties": {
|
|
536
|
+
"id": {
|
|
537
|
+
"const": "sharpen"
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
"minContains": 0,
|
|
542
|
+
"maxContains": 1
|
|
543
|
+
}
|
|
544
|
+
]
|
|
373
545
|
}
|
|
374
546
|
},
|
|
375
547
|
"$comment": "正本は docs/contract-2026-09-05-clip-adjust-v1.md。v2 visual item の per-clip 補正で、未指定の値は中立、sections は false のときだけ該当段をバイパスする。"
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
{ "path": "tracks[].items[].crop", "applies_to": ["cuts", "layers", "baked"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts layerStyleVisualAt/resolvedCompositeLayers" },
|
|
26
26
|
{ "path": "tracks[].items[].crop", "applies_to": ["overlays", "captions", "group"], "gpu": "ignored", "osr": "ignored", "evidence": "packages/render-cut/src/rasterize.mjs renderOverlayNode applies transform vars only", "hint": "overlay の HTML/CSS 内で clipping を表現する" },
|
|
27
27
|
{ "path": "tracks[].items[].adjust", "applies_to": ["cuts", "layers", "baked"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts resolveAdjustLut; packages/frame-engine/src/compositor/webgl2.ts configureAdjustLut" },
|
|
28
|
+
{ "path": "tracks[].items[].adjust.fx", "applies_to": ["cuts", "layers", "baked"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/compositor/webgl2.ts applyFx" },
|
|
28
29
|
{ "path": "tracks[].items[].adjust", "applies_to": ["overlays", "captions", "group"], "gpu": "ignored", "osr": "ignored", "evidence": "packages/render-cut/src/rasterize.mjs renderOverlayNode applies transform vars only", "hint": "overlay の HTML/CSS 内で色調整を表現する" },
|
|
29
30
|
{ "path": "tracks[].items[].perspective", "applies_to": ["cuts"], "gpu": "ignored", "osr": "ignored", "runtime_warning": true, "evidence": "packages/frame-engine/src/timeline/plan.ts cutDeclaresPerspective warning; layerStyleVisualAt does not read perspective", "issue": 39, "hint": "layers 経路(mask / 非 normal blend)へ寄せるか perspective を外す" },
|
|
30
31
|
{ "path": "tracks[].items[].perspective", "applies_to": ["layers", "baked"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts resolvedCompositeLayers" },
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"output": {
|
|
4
|
+
"width": 1920,
|
|
5
|
+
"height": 1080,
|
|
6
|
+
"fps": 30
|
|
7
|
+
},
|
|
8
|
+
"sources": [
|
|
9
|
+
{
|
|
10
|
+
"id": "main",
|
|
11
|
+
"path": "main.mp4"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"tracks": [
|
|
15
|
+
{
|
|
16
|
+
"id": "visual",
|
|
17
|
+
"lane": "visual",
|
|
18
|
+
"items": [
|
|
19
|
+
{
|
|
20
|
+
"id": "clip-1",
|
|
21
|
+
"at": 0,
|
|
22
|
+
"duration": 30,
|
|
23
|
+
"crop": {
|
|
24
|
+
"x": 0,
|
|
25
|
+
"y": 0,
|
|
26
|
+
"w": 1,
|
|
27
|
+
"h": 1
|
|
28
|
+
},
|
|
29
|
+
"adjust": {
|
|
30
|
+
"fx": [
|
|
31
|
+
{
|
|
32
|
+
"id": "grain",
|
|
33
|
+
"amount": 0.2
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "grain",
|
|
37
|
+
"amount": 0.8
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"sections": {
|
|
41
|
+
"fx": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"source": {
|
|
45
|
+
"kind": "media",
|
|
46
|
+
"src": "main",
|
|
47
|
+
"in": 0,
|
|
48
|
+
"out": 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"output": {
|
|
4
|
+
"width": 1920,
|
|
5
|
+
"height": 1080,
|
|
6
|
+
"fps": 30
|
|
7
|
+
},
|
|
8
|
+
"sources": [
|
|
9
|
+
{
|
|
10
|
+
"id": "main",
|
|
11
|
+
"path": "main.mp4"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"tracks": [
|
|
15
|
+
{
|
|
16
|
+
"id": "visual",
|
|
17
|
+
"lane": "visual",
|
|
18
|
+
"items": [
|
|
19
|
+
{
|
|
20
|
+
"id": "clip-1",
|
|
21
|
+
"at": 0,
|
|
22
|
+
"duration": 30,
|
|
23
|
+
"crop": {
|
|
24
|
+
"x": 0,
|
|
25
|
+
"y": 0,
|
|
26
|
+
"w": 1,
|
|
27
|
+
"h": 1
|
|
28
|
+
},
|
|
29
|
+
"adjust": {
|
|
30
|
+
"fx": [
|
|
31
|
+
{
|
|
32
|
+
"id": "blur",
|
|
33
|
+
"px": 51
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"sections": {
|
|
37
|
+
"fx": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"source": {
|
|
41
|
+
"kind": "media",
|
|
42
|
+
"src": "main",
|
|
43
|
+
"in": 0,
|
|
44
|
+
"out": 1
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"output": {
|
|
4
|
+
"width": 1920,
|
|
5
|
+
"height": 1080,
|
|
6
|
+
"fps": 30
|
|
7
|
+
},
|
|
8
|
+
"sources": [
|
|
9
|
+
{
|
|
10
|
+
"id": "main",
|
|
11
|
+
"path": "main.mp4"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"tracks": [
|
|
15
|
+
{
|
|
16
|
+
"id": "visual",
|
|
17
|
+
"lane": "visual",
|
|
18
|
+
"items": [
|
|
19
|
+
{
|
|
20
|
+
"id": "clip-1",
|
|
21
|
+
"at": 0,
|
|
22
|
+
"duration": 30,
|
|
23
|
+
"crop": {
|
|
24
|
+
"x": 0,
|
|
25
|
+
"y": 0,
|
|
26
|
+
"w": 1,
|
|
27
|
+
"h": 1
|
|
28
|
+
},
|
|
29
|
+
"adjust": {
|
|
30
|
+
"fx": [
|
|
31
|
+
{
|
|
32
|
+
"id": "glow"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"sections": {
|
|
36
|
+
"fx": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"source": {
|
|
40
|
+
"kind": "media",
|
|
41
|
+
"src": "main",
|
|
42
|
+
"in": 0,
|
|
43
|
+
"out": 1
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"output": {
|
|
4
|
+
"width": 1920,
|
|
5
|
+
"height": 1080,
|
|
6
|
+
"fps": 30
|
|
7
|
+
},
|
|
8
|
+
"sources": [
|
|
9
|
+
{
|
|
10
|
+
"id": "main",
|
|
11
|
+
"path": "main.mp4"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"tracks": [
|
|
15
|
+
{
|
|
16
|
+
"id": "visual",
|
|
17
|
+
"lane": "visual",
|
|
18
|
+
"items": [
|
|
19
|
+
{
|
|
20
|
+
"id": "clip-1",
|
|
21
|
+
"at": 0,
|
|
22
|
+
"duration": 30,
|
|
23
|
+
"crop": {
|
|
24
|
+
"x": 0,
|
|
25
|
+
"y": 0,
|
|
26
|
+
"w": 1,
|
|
27
|
+
"h": 1
|
|
28
|
+
},
|
|
29
|
+
"adjust": {
|
|
30
|
+
"fx": [
|
|
31
|
+
{
|
|
32
|
+
"id": "vignette",
|
|
33
|
+
"amount": 0.5,
|
|
34
|
+
"midpoint": 0.5,
|
|
35
|
+
"roundness": 0,
|
|
36
|
+
"feather": 0.5
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "blur",
|
|
40
|
+
"px": 8
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "grain",
|
|
44
|
+
"amount": 0.3,
|
|
45
|
+
"size": 1
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "sharpen",
|
|
49
|
+
"amount": 0.5
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"sections": {
|
|
53
|
+
"fx": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"source": {
|
|
57
|
+
"kind": "media",
|
|
58
|
+
"src": "main",
|
|
59
|
+
"in": 0,
|
|
60
|
+
"out": 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
7
|
+
import { fxExamples, invalidFxCases } from './fixtures/adjust-fx-cases.mjs';
|
|
8
|
+
|
|
9
|
+
const schema = JSON.parse(readFileSync(new URL('../edit.schema.json', import.meta.url), 'utf8'));
|
|
10
|
+
const validate = new Ajv2020({ strict: false }).compile({ $defs: schema.$defs, $ref: '#/$defs/adjustV1' });
|
|
11
|
+
for (const { name, check, url, edit } of fxExamples) {
|
|
12
|
+
test('schema adjust fx example: ' + name, () => {
|
|
13
|
+
assert.equal(validate(edit.tracks[0].items[0].adjust), !check, JSON.stringify(validate.errors));
|
|
14
|
+
});
|
|
15
|
+
test('validate-edit CLI adjust fx example: ' + name, () => {
|
|
16
|
+
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../bin/validate-edit.mjs', import.meta.url)), fileURLToPath(url)], { encoding: 'utf8' });
|
|
17
|
+
assert.equal(result.status, check ? 1 : 0, result.stderr || String(result.error));
|
|
18
|
+
if (check) assert.ok(result.stderr.includes(check), result.stderr);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
test('schema adjust fx rejects closed-vocabulary, structure and range violations even when bypassed', () => {
|
|
22
|
+
for (const [name, adjust] of invalidFxCases) {
|
|
23
|
+
assert.equal(validate(adjust), false, name);
|
|
24
|
+
if (adjust.fx !== undefined) assert.equal(validate({ ...adjust, sections: { fx: false } }), false, name);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
test('schema adjust fx accepts omission, defaults, empty arrays and inclusive boundaries', () => {
|
|
28
|
+
for (const adjust of [{}, { fx: [] }, { sections: { fx: false } }, {
|
|
29
|
+
fx: [{ id: 'vignette', amount: -1, midpoint: 0, roundness: -1, feather: 1 }, { id: 'blur', px: 50 }, { id: 'grain', amount: 1, size: 0.5 }, { id: 'sharpen', amount: 0 }],
|
|
30
|
+
}, { fx: [{ id: 'vignette' }, { id: 'blur' }, { id: 'grain' }, { id: 'sharpen' }] }]) {
|
|
31
|
+
assert.equal(validate(adjust), true, JSON.stringify(validate.errors));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -38,9 +38,13 @@ test('all eight look files conform to adjustV1 and exactly match index ids', ()
|
|
|
38
38
|
assert.equal(JSON.stringify(look).includes('vignette'), false);
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
|
-
test('capability ledger
|
|
41
|
+
test('capability ledger keeps the two parent adjust rows and the fx row', () => {
|
|
42
42
|
const table = JSON.parse(readFileSync(new URL('../engine-capabilities.json', import.meta.url), 'utf8'));
|
|
43
|
-
const rows = table.fields.filter(row => row.path
|
|
43
|
+
const rows = table.fields.filter(row => row.path === 'tracks[].items[].adjust');
|
|
44
|
+
const fx = table.fields.filter(row => row.path === 'tracks[].items[].adjust.fx');
|
|
45
|
+
assert.equal(fx.length, 1);
|
|
46
|
+
assert.deepEqual([fx[0].gpu, fx[0].osr], ['consumed', 'consumed']);
|
|
47
|
+
assert.equal(fx[0].evidence, 'packages/frame-engine/src/compositor/webgl2.ts applyFx');
|
|
44
48
|
assert.equal(rows.length, 2);
|
|
45
49
|
assert.ok(rows.every(row => row.path === 'tracks[].items[].adjust'));
|
|
46
50
|
assert.deepEqual(rows.map(row => row.applies_to), [['cuts', 'layers', 'baked'], ['overlays', 'captions', 'group']]);
|
|
@@ -19,6 +19,8 @@ const canonicalPaths = new Set([
|
|
|
19
19
|
...ITEM_SOURCE_V2_KEYS.map((key) => `tracks[].items[].source.${key}`),
|
|
20
20
|
...KEYFRAME_V2_KEYS.map((key) => `tracks[].items[].keyframes[].${key}`),
|
|
21
21
|
]);
|
|
22
|
+
// Spatial effects have a dedicated nested capability below the generated item-level adjust key.
|
|
23
|
+
const capabilityPaths = new Set([...canonicalPaths, 'tracks[].items[].adjust.fx']);
|
|
22
24
|
const appliesToVocabulary = new Set(["cuts", "layers", "overlays", "baked", "audio", "captions", "group"]);
|
|
23
25
|
|
|
24
26
|
test("engine capability table declares the version, engines, and status vocabulary", () => {
|
|
@@ -30,11 +32,12 @@ test("engine capability table declares the version, engines, and status vocabula
|
|
|
30
32
|
test("all 68 generated item, source, and keyframe keys have a capability row", () => {
|
|
31
33
|
assert.equal(canonicalPaths.size, 68);
|
|
32
34
|
const covered = new Set(table.fields.map((field) => field.path));
|
|
35
|
+
assert.ok(covered.has('tracks[].items[].adjust.fx'));
|
|
33
36
|
assert.deepEqual([...canonicalPaths].filter((path) => !covered.has(path)), []);
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
test("capability rows cannot invent paths outside the generated v2 key inventory", () => {
|
|
37
|
-
assert.deepEqual(table.fields.filter((field) => !
|
|
40
|
+
assert.deepEqual(table.fields.filter((field) => !capabilityPaths.has(field.path)), []);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
test("every capability row uses only declared engine status values", () => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const fxExamples = [
|
|
4
|
+
['valid', null],
|
|
5
|
+
['unknown-id-invalid', 'adjust.fx.id'],
|
|
6
|
+
['range-invalid', 'adjust.fx.range'],
|
|
7
|
+
['duplicate-invalid', 'adjust.fx.duplicate-id'],
|
|
8
|
+
].map(([name, check]) => {
|
|
9
|
+
const url = new URL('../../examples/edit-v2-adjust-fx-' + name + '/edit.json', import.meta.url);
|
|
10
|
+
return { name, check, url, edit: JSON.parse(readFileSync(url, 'utf8')) };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const invalidFxCases = [
|
|
14
|
+
['nonarray', { fx: {} }, 'adjust.fx.structure'],
|
|
15
|
+
['null array', { fx: null }, 'adjust.fx.structure'],
|
|
16
|
+
['nonobject entry', { fx: [null] }, 'adjust.fx.structure'],
|
|
17
|
+
['array entry', { fx: [[]] }, 'adjust.fx.structure'],
|
|
18
|
+
['missing id', { fx: [{}] }, 'adjust.fx.id'],
|
|
19
|
+
['inherited id', { fx: [{ id: 'toString' }] }, 'adjust.fx.id'],
|
|
20
|
+
['extra parameter', { fx: [{ id: 'blur', amount: 0.2 }] }, 'adjust.unknown-key'],
|
|
21
|
+
['nine entries', { fx: Array.from({ length: 9 }, () => ({ id: 'blur' })) }, 'adjust.fx.max-items'],
|
|
22
|
+
['nonboolean section', { sections: { fx: 'off' } }, 'adjust.sections.fx'],
|
|
23
|
+
...Object.entries({
|
|
24
|
+
vignette: { amount: [-1, 1], midpoint: [0, 1], roundness: [-1, 1], feather: [0, 1] },
|
|
25
|
+
blur: { px: [0, 50] },
|
|
26
|
+
grain: { amount: [0, 1], size: [0.5, 4] },
|
|
27
|
+
sharpen: { amount: [0, 1] },
|
|
28
|
+
}).flatMap(([id, params]) => Object.entries(params).flatMap(([key, [min, max]]) =>
|
|
29
|
+
[min - 0.01, max + 0.01, '0', null, NaN, Infinity, -Infinity].map(value =>
|
|
30
|
+
[id + '.' + key + '=' + String(value), { fx: [{ id, [key]: value }] }, 'adjust.fx.range']))),
|
|
31
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const readSource = path => readFileSync(new URL(path, import.meta.url), "utf8");
|
|
6
|
+
const schemaKeys = Object.keys(JSON.parse(readSource("../captions.schema.json")).$defs.textStyle.properties);
|
|
7
|
+
|
|
8
|
+
function extractKeys(source, identifier) {
|
|
9
|
+
const block = source.match(new RegExp(`\\b${identifier}\\s*=\\s*new\\s+Set\\s*\\(\\s*\\[([\\s\\S]*?)\\]\\s*\\)`));
|
|
10
|
+
assert.ok(block, `${identifier}: new Set([...]) block must be found`);
|
|
11
|
+
const keys = new Set([...block[1].matchAll(/["']([^"']+)["']/gu)].map(match => match[1]));
|
|
12
|
+
assert.ok(keys.size > 0, `${identifier}: extracted keys must not be empty`);
|
|
13
|
+
return keys;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A/B/D のどれか 1 か所から schema のキーを故意に消せば、この包含検査は落ちる。
|
|
17
|
+
// legacy キー等の余分なキーは許す(schema ⊆ allowlist の片方向包含)。
|
|
18
|
+
for (const [identifier, path] of [
|
|
19
|
+
["allowedKeys", "../bin/validate-captions.mjs"],
|
|
20
|
+
["CAPTION_STYLE_KEYS", "../../edit-store/src/caption-display.ts"],
|
|
21
|
+
["TEXT_STYLE_KEYS", "../../edit-store/src/caption-store.ts"],
|
|
22
|
+
]) {
|
|
23
|
+
test(`${identifier} contains every schema textStyle key`, () => {
|
|
24
|
+
const keys = extractKeys(readSource(path), identifier);
|
|
25
|
+
for (const key of schemaKeys) assert.ok(keys.has(key), `${identifier} is missing ${key}`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("edit-lint derives CAPTION_TEXT_STYLE_FIELDS directly from the schema", () => {
|
|
30
|
+
// C は手書き Set ではない。schema からの導出が維持されることを固定する。
|
|
31
|
+
assert.match(readSource("../../edit-lint/src/edit-lint.mjs"),
|
|
32
|
+
/\bCAPTION_TEXT_STYLE_FIELDS\s*=\s*new\s+Set\s*\(\s*Object\.keys\(CAPTIONS_SCHEMA\.\$defs\.textStyle\.properties\),?\s*\)/u);
|
|
33
|
+
});
|
|
@@ -323,3 +323,27 @@ test("caption record の style_preset は文字列型と id 形式を検査す
|
|
|
323
323
|
assert.match(executed.stderr, /style_preset/u);
|
|
324
324
|
}
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
test("max_characters accepts default and per-caption positive integers", () => {
|
|
328
|
+
const executed = runValue({
|
|
329
|
+
default_text_style: { max_characters: 12 },
|
|
330
|
+
captions: [{ ...caption, text_style: { max_characters: 8 } }],
|
|
331
|
+
});
|
|
332
|
+
assert.equal(executed.status, 0, executed.stderr);
|
|
333
|
+
assert.match(executed.stdout, /^OK: /);
|
|
334
|
+
assert.doesNotMatch(executed.stderr, /未知のキー/u);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("max_characters rejects non-positive integers and invalid types in either style seat", () => {
|
|
338
|
+
for (const value of [0, -1, 1.5, "12", null, true]) {
|
|
339
|
+
for (const isDefault of [true, false]) {
|
|
340
|
+
const executed = runValue({
|
|
341
|
+
...(isDefault ? { default_text_style: { max_characters: value } } : {}),
|
|
342
|
+
captions: [{ ...caption, ...(!isDefault ? { text_style: { max_characters: value } } : {}) }],
|
|
343
|
+
});
|
|
344
|
+
assert.equal(executed.status, 1, executed.stdout);
|
|
345
|
+
assert.match(executed.stderr, /max_characters は 0 より大きい整数/u);
|
|
346
|
+
assert.doesNotMatch(executed.stderr, /未知のキー/u);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|