akari-video 0.1.45 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
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-vgpu-layer-v0.md",
67
68
  "packages/akari-launcher/package.json",
68
69
  "packages/akari-launcher/README.md",
69
70
  "packages/akari-tools/package.json",
@@ -21,6 +21,7 @@
21
21
  |---|---|---|
22
22
  | `same` | はい | 静的 HTML は起動時、対応済み字幕は unit の初回活性時に 1 回だけスプライト化する |
23
23
  | `three` | はい | JSON の宣言型 3D scene と描画先 canvas を持つ overlay。毎コマ Three.js canvas を更新し、登場表現は `three-scene-entrance-curve`、`three-scene-entrance-sampled`、または `three-scene-sampled-composite` で処理する |
24
+ | `vgpu` | はい | pure な `data-akari-vgpu-scene` を WebGPU canvas に描き、毎コマスプライトへ転送する([vgpu v0 契約](./contract-2026-09-06-vgpu-layer-v0.md)) |
24
25
  | `degraded` | いいえ | raster 自体は可能でも live DOM と同じ時間変化を保証できない |
25
26
  | `unsupported` | いいえ | v0 の表現範囲外であり、正しい完成画を生成できない |
26
27
 
@@ -320,6 +321,8 @@ CSS 3D は次の 3 群に分けて判定する。
320
321
  子孫の描画域が画面上で交差し、手前の要素が DOM 上で先に描かれる矛盾対を検出した場合は警告するが、
321
322
  fail-closed にはしない。
322
323
 
324
+ vgpu の理由語彙は `vgpu-scene-canvas-direct`、`vgpu-stateful-unsupported`、`vgpu-invalid-declaration`、`vgpu-condition:<条件をカンマ連結>`([vgpu v0 契約](./contract-2026-09-06-vgpu-layer-v0.md))。
325
+
323
326
  次の条件は fail-closed のまま `degraded` とし、receipt に overlay id、理由、検出条件を全件残す。
324
327
 
325
328
  - `iframe`、`object`、`embed` の埋め込み context。
@@ -128,6 +128,11 @@ ms へ換算し、絶対値で集計する。全 59 点の最大は 16.667 ms、
128
128
  書き出しはこの speech 鍵を消費する。プレビューサーバーへ speech 鍵を渡す配線は同契約の範囲外で、
129
129
  配線されるまでは narration 鍵だけへ劣化する。
130
130
 
131
+ `tracks[].items[].source`(`kind: 'media'`)の `gain_db`(-60〜12・省略時 0)と
132
+ `mute`(boolean・省略時 false)で、カットごとに埋め込み音声を減衰 / ミュートできる。
133
+ `mute: true` は speech 宣言そのものを作らず、プレビューのサイドカー要求も PCM 化のコストも消える。
134
+ 書き出しは無音区間で尺を保つ。キー無しの既定値は従来と完全同一で、既存案件の出力は変わらない。
135
+
131
136
  ## 7. 速度変更した台詞のピッチ保持サイドカー
132
137
 
133
138
  `speed` が 1 以外の撮影素材の台詞は、Web Audio の `playbackRate` による近似から外す。
@@ -0,0 +1,166 @@
1
+ ---
2
+ lifecycle: accepted
3
+ date: 2026-09-06
4
+ ---
5
+
6
+ # vgpu レイヤー v0 契約
7
+
8
+ ## 1. 適用範囲
9
+
10
+ `data-akari-vgpu-scene` に宣言した WebGPU のフルスクリーン fragment パスを、
11
+ `window.akari.vgpuRuntime` が外部から渡された時刻で描く。vgpu 0.4.0 は vendor 同梱し、
12
+ 実行時の依存取得は行わない。Three.js と同じ overlay sheet に置き、GPU 書き出しでは
13
+ canvas を SpriteCompositor に登録する。毎コマ `render` の直後に `updateSprite` する。
14
+ WebGPU canvas の提示面は一時的なので、描画せずに前回の canvas を転送してはならない。
15
+ CPU への画素読み戻しはこの経路に追加しない。ブラウザ内部のゼロコピーを保証するものではない。
16
+
17
+ ## 2. 宣言スキーマ
18
+
19
+ 断片に次の script をちょうど 1 個置く。canvas は任意で、省略時はランタイムが作成する。
20
+
21
+ ```html
22
+ <canvas></canvas>
23
+ <script type="application/json" data-akari-vgpu-scene>
24
+ {
25
+ "version": 0,
26
+ "mode": "pure",
27
+ "alphaMode": "premultiplied",
28
+ "seed": 0,
29
+ "uniforms": { "speed": 1 },
30
+ "passes": [{
31
+ "id": "main",
32
+ "wgsl": "struct Params { speed: f32 }; @group(0) @binding(1) var<uniform> params: Params; @fragment fn fs_main(@builtin(position) p: vec4f) -> @location(0) vec4f { let uv = akari_uv(p); return vec4f(uv, 0.5 + 0.5 * sin(akari.time * params.speed), 1.0); }",
33
+ "inputs": [],
34
+ "scale": 1
35
+ }]
36
+ }
37
+ </script>
38
+ ```
39
+
40
+ | 項目 | 制約・既定値 |
41
+ |---|---|
42
+ | `version` | 必須、数値 `0` のみ |
43
+ | `mode` | 必須、`pure` のみ。`stateful` は予約語で拒否 |
44
+ | `alphaMode` | 省略時 `premultiplied`。それ以外は拒否。シェーダー出力の RGB も alpha 乗算済みにする |
45
+ | `seed` | 任意の有限数。省略時 `0` |
46
+ | `uniforms` | 任意の plain object。値は有限数、または長さ 2〜4 の有限数配列。省略時 `{}` |
47
+ | `passes` | 必須、1 個以上の配列 |
48
+ | `passes[].id` | 必須、`^[A-Za-z0-9_-]+$`、一意 |
49
+ | `passes[].wgsl` | 必須、空白だけではない WGSL 文字列 |
50
+ | `passes[].inputs` | 先行パス id の文字列配列、最大 8 個。省略時 `[]`。自己参照・前方参照不可 |
51
+ | `passes[].scale` | 正の有限数、省略時 `1`。最終パスでは無視する |
52
+
53
+ 未知のトップレベルキー・pass キー、不正 JSON、不正な値は `readDescriptor` が TypeError にする。
54
+ `uniforms` のキーは WGSL `Params` のメンバーに対応し、断片が
55
+ `@group(0) @binding(1) var<uniform> params: Params;` を自分で宣言する。
56
+ 型は `f32` / `vec2f` / `vec3f` / `vec4f`。params 宣言のないパスには params を set しない。
57
+ 未宣言名・未束縛・WGSL コンパイルエラーは vgpu の検証を通じて失敗する。
58
+
59
+ `pure` は「出力が指定時刻・seed・uniforms の純関数」の authoring 契約である。
60
+ 任意時刻へ直接シークでき、前回描画結果への feedback はない。任意の WGSL の数学的な純粋性を
61
+ 静的に証明するものではない。時刻を進める時計や乱数源を断片へ提供しない。
62
+
63
+ ## 3. 固定 prelude とパス
64
+
65
+ 各パスのソース先頭に次を付加する。
66
+
67
+ ```wgsl
68
+ struct AkariUniforms { time: f32, aspect: f32, width: f32, height: f32, seed: f32, pad: vec3f };
69
+ @group(0) @binding(0) var<uniform> akari: AkariUniforms;
70
+ fn akari_uv(pos: vec4f) -> vec2f { return pos.xy / vec2f(akari.pad.x, akari.pad.y); }
71
+ ```
72
+
73
+ 入力があるパスには、入力数だけ texture 宣言と、共通 sampler 宣言を続ける。断片側で重複宣言しない。
74
+
75
+ ```wgsl
76
+ @group(1) @binding(0) var input_0: texture_2d<f32>;
77
+ // input_1 ... input_7 は必要な個数だけ binding 1 ... 7 に宣言
78
+ @group(1) @binding(8) var input_sampler: sampler;
79
+ ```
80
+
81
+ パスは配列順に、1 回の `frame(gpu, f => ...)` で直列描画する。
82
+ 最後のパスだけが surface、それ以前は `scale` 倍のオフスクリーン target へ描く。
83
+ `input_i` には参照先 target の `color` を渡す。resize 後は新しい texture を再束縛する。
84
+ `effect.draw(surface)` は使用しない(0.4.0 の surface は `frame` の `pass` で描く)。
85
+
86
+ `akari.width/height` は常に等倍出力の container 実寸で、`aspect = width / height`。
87
+ **`pad` は予約した形を維持しつつ実値を運ぶ**: x/y は当該パスの描画バッファ実幅・実高、z は previewScale。
88
+ 最終パスの x/y は canvas 実寸、中間パスはさらに pass.scale を掛けた target 実寸。
89
+ 固定 struct を変えず、`@builtin(position)` のピクセル座標を解像度に依存せず正規化するための裁定補足である。
90
+ 断片は `akari_uv(position)`、または vgpu の頂点段が供給する左上原点の `@location(0) uv` を使う。
91
+ `position.xy / vec2f(akari.width, akari.height)` は縮小時に構図が変わるので使わない。
92
+
93
+ ## 4. API・解像度・ツマミ
94
+
95
+ - `probe()` はページで 1 回だけ `init()` し、同じ Promise を返す。64×64 一時 surface に 2 コマ描画し、
96
+ GPU queue 完了と検証完了を待つ。device lost を初期化直後から監視し、一時 surface を破棄する。
97
+ 成功値は `{ ok: true, adapter: { vendor, architecture }, ms }`。情報取得不可の文字列は空文字。
98
+ - `render(container, localTimeSeconds, { previewScale })` は同期描画。時刻の源は引数のみ。
99
+ 未初期化時は probe を開始して loading のまま戻る。書き出しホストは必ず先に probe を await する。
100
+ 描画の同期エラーは `VGPU-RENDER:` を投げ、以後その container は no-op とする。
101
+ 書き出しホストは inspect の error も確認し、空の層を成功扱いしない。
102
+ - `inspect(container)` は `{ status, adapter, passes, previewScale, drawCount, deviceLost }`。
103
+ status は `idle`(宣言なし)、`loading`、`ready`、`error`。passes は構築したパス数。
104
+ - `dispose(container)` は surface・target と自動作成 canvas を破棄する。共有 device は維持する。
105
+ - `readDescriptor(container)` は宣言なしなら null、宣言不正なら TypeError。
106
+
107
+ previewScale は辺あたり倍率で、未指定 `1`。推奨値は `1` / `0.5` / `0.25` だが、
108
+ 実装は有限数 `0 < s <= 1` を受け付ける。範囲外は警告 1 回で `1` にする。
109
+ preview-server の既定は `PREVIEW_VGPU_SCALE = 0.5`。書き出しは options を渡さず常に `1`。
110
+ canvas の CSS は absolute / inset 0 / width・height 100% / display block に固定する。
111
+ 内部画素だけが減り、位置・大きさ・時刻・ツマミは変わらない。描画寸法は丸め、最小 1 px。
112
+ container が 0 サイズなら前回寸法を使い、前回もなければ描画しない。
113
+ GPU 合成側は container の解決済み配置変換をスプライトへ渡し、プレビューの位置と拡縮を維持する。
114
+
115
+ `--vgpu-<key>` が container の CSS にある場合は uniforms より優先する。
116
+ 数値または空白・カンマ区切りのベクトルを読み、宣言と同じ要素数で全部有限数の場合だけ採用する。
117
+ 不一致はキーごとに警告 1 回で宣言値を使う。既存の `vars` からそのまま設定できる。
118
+
119
+ ## 5. 適格性と receipt
120
+
121
+ `item-keyframes` の後、three-only 判定の前に vgpu 判定を置く。
122
+ 検出条件は `vgpu-runtime`、canvas があればその次に `three-or-canvas-runtime`。
123
+
124
+ | 分類 | 理由 |
125
+ |---|---|
126
+ | `vgpu` | `vgpu-scene-canvas-direct`: pure 宣言が妥当で、他条件が canvas 由来だけ |
127
+ | `degraded` | `vgpu-stateful-unsupported`: stateful |
128
+ | `degraded` | `vgpu-invalid-declaration`: JSON・version・mode・スキーマ不正 |
129
+ | `degraded` | `vgpu-condition:<条件をカンマ連結>`: 他の動的条件や外部参照を含む |
130
+
131
+ 3D 宣言が同居する場合は、優先して
132
+ `vgpu-condition:three-or-canvas-runtime(data-akari-3d-scene)` とする。
133
+ その後、他条件、宣言検証の順に判定する。JSON はちょうど 1 宣言だけを受け付ける。
134
+ `forceDegraded` は pure の vgpu 分類を変更しない。degraded になった vgpu は既存の規則どおり
135
+ `forced-dom:<元理由>` で DOM 強制の対象となり、eligible は false のまま残る。
136
+
137
+ `summary.vgpu` は件数が 1 以上のときだけ追加する。`spriteManifest.vgpu` は常に配列で、
138
+ 各要素は `{ id, start, duration, index, z }`。overlay sheet は three と vgpu のいずれかがある場合だけ作る。
139
+ receipt の `gpu.vgpu` は run に vgpu があるときだけ、次の形で追加する。
140
+
141
+ ```json
142
+ { "overlays": 2, "adapter": { "vendor": "apple", "architecture": "metal-3" },
143
+ "previewScale": null, "deviceLost": false, "probeMs": 12.5 }
144
+ ```
145
+
146
+ overlays は有限非負整数、adapter は文字列、previewScale は数値または null、
147
+ deviceLost は boolean、probeMs は有限非負数または null に正規化する。
148
+ summary と receipt を条件付きにしたのは、vgpu を使わない既存 fixture・receipt を byte/deep-equal で保つため。
149
+ vgpu 不在の overlay sheet には新しい script や seek 文を一切挿入しない。
150
+
151
+ ## 6. fail-loud と v0 の限界
152
+
153
+ - WebGPU 未提供・adapter null・初期化/試験描画失敗は `VGPU-UNAVAILABLE:` で probe を reject。
154
+ - device lost 後の render は `VGPU-DEVICE-LOST:` で throw。復帰や別 device への切り替えは行わない。
155
+ - `auto` でも `VGPU-` を含む失敗は OSR へフォールバックせずそのまま伝播する。
156
+ - プレビューは失敗を捕捉し `[data-akari-vgpu-fallback]`(任意、断片の既定 display:none)を表示して、
157
+ console.warn を 1 回出す。他のプレビューは続行する。
158
+
159
+ CLI の `--engine legacy` は既に廃止され、引数処理で拒否される。
160
+ その CLI のエラーを変更せず、`renderProject(..., { engine: 'legacy' })` の API 直呼びには
161
+ vgpu 宣言を検出した時点で `vgpu overlays require --engine gpu` を返す。これが元裁定の
162
+ 「engine 解決後の legacy 拒否」と異なる理由は、CLI からその分岐に到達できないためである。
163
+
164
+ stateful・feedback・固定ステップ replay は未対応。OSR は同じ sheet を通すが v0 のパリティ gate 対象外。
165
+ Windows / Linux / 他 GPU は未検証。同一機での 2 走・全コマ SHA 一致は実 GPU の検収で確認する事項であり、
166
+ 異機種間の浮動小数点の一致を保証しない。SwiftShader を WebGPU の代替として採用しない。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
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": [
@@ -77,6 +77,7 @@ export interface WebAudioSpeechCut extends EditCut {
77
77
  duration_sec?: unknown;
78
78
  } | null;
79
79
  gain_db?: unknown;
80
+ mute?: unknown;
80
81
  gainDb?: unknown;
81
82
  volume_db?: unknown;
82
83
  transition_out?: EditCut['transitionOut'] | null;
@@ -299,6 +299,8 @@ function projectSpeechDeclarations(cuts, options) {
299
299
  const cut = normalizedCuts[segment.cutIndex];
300
300
  if (!cut || typeof cut.src !== 'string' || !cut.src)
301
301
  continue;
302
+ if (cut.mute === true)
303
+ continue;
302
304
  const speed = finitePositive(cut.speed) ? cut.speed : 1;
303
305
  const segmentIn = typeof segment.in === 'number' ? segment.in : cut.in;
304
306
  const cutTimelineStart = segment.outStart - (segmentIn - cut.in) / speed;
@@ -91,6 +91,10 @@ export interface MediaSourceV2 {
91
91
  freeze?: Record<string, unknown> | null;
92
92
  fx?: unknown[];
93
93
  speed?: number;
94
+ /** 埋め込み音声(speech)の音量 dB。-60〜12・省略時 0。 */
95
+ gain_db?: number;
96
+ /** 埋め込み音声(speech)のミュート。省略時 false。 */
97
+ mute?: boolean;
94
98
  chroma_key?: Record<string, unknown> | null;
95
99
  }
96
100
  export interface AudioMediaSourceV2 {
@@ -302,7 +302,7 @@ function validateItemSource(value, path, sourceIds) {
302
302
  switch (value.kind) {
303
303
  case 'media':
304
304
  requireExactKeys(value, new Set([
305
- 'kind', 'src', 'in', 'out', 'framing', 'transition_out', 'freeze', 'fx', 'speed', 'chroma_key'
305
+ 'kind', 'src', 'in', 'out', 'framing', 'transition_out', 'freeze', 'fx', 'speed', 'chroma_key', 'gain_db', 'mute'
306
306
  ]), path);
307
307
  requireText(value.src, `${path}.src`);
308
308
  if (!sourceIds.has(value.src))
@@ -319,6 +319,10 @@ function validateItemSource(value, path, sourceIds) {
319
319
  throw invalid(`${path}.fx`, '配列である必要があります');
320
320
  if (hasOwn(value, 'speed'))
321
321
  requirePositiveNumber(value.speed, `${path}.speed`);
322
+ if (hasOwn(value, 'gain_db'))
323
+ requireRange(value.gain_db, -60, 12, `${path}.gain_db`);
324
+ if (hasOwn(value, 'mute') && typeof value.mute !== 'boolean')
325
+ throw invalid(`${path}.mute`, 'boolean である必要があります');
322
326
  return;
323
327
  case 'html':
324
328
  requireExactKeys(value, new Set(['kind', 'path', 'part', 'style', 'text', 'exclude', 'derivedFrom', 'vars', 'params']), path);
@@ -1,5 +1,5 @@
1
1
  export declare const ITEM_V2_KEYS: readonly ["id", "name", "hidden", "locked", "at", "duration", "anchor", "transform", "opacity", "blend", "crop", "adjust", "perspective", "motion", "animator", "keyframes", "items", "mask", "source", "role", "gain_db", "denoise", "lowcut_hz", "fade_in", "fade_out", "ducking", "duck_db", "duck_attack", "duck_release", "script", "reading", "provenance"];
2
- export declare const ITEM_SOURCE_V2_KEYS: readonly ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "chroma_key", "pitch_semitones", "formant", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params", "shape", "preset", "baked", "from", "filter", "id"];
2
+ export declare const ITEM_SOURCE_V2_KEYS: readonly ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "gain_db", "mute", "chroma_key", "pitch_semitones", "formant", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params", "shape", "preset", "baked", "from", "filter", "id"];
3
3
  export declare const KEYFRAME_V2_KEYS: readonly ["t", "transform", "crop", "perspective", "opacity", "gain_db", "animator", "easing"];
4
4
  export declare const MOTION_V0_KEYS: readonly ["in", "out", "loop"];
5
5
  export declare const ANIMATOR_V0_KEYS: readonly ["id", "basis", "shape", "start", "end", "offset", "randomize", "amount", "ease"];
@@ -17,7 +17,7 @@ export declare const ITEM_V2_KEYS_BY_DEFINITION: {
17
17
  readonly itemV2AudioMedia: readonly ["id", "name", "hidden", "locked", "at", "duration", "role", "source", "gain_db", "denoise", "lowcut_hz", "keyframes", "fade_in", "fade_out", "ducking", "duck_db", "duck_attack", "duck_release", "script", "reading", "provenance"];
18
18
  };
19
19
  export declare const ITEM_SOURCE_V2_KEYS_BY_DEFINITION: {
20
- readonly itemSourceMediaV2: readonly ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "chroma_key"];
20
+ readonly itemSourceMediaV2: readonly ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "gain_db", "mute", "chroma_key"];
21
21
  readonly itemSourceAudioMediaV2: readonly ["kind", "src", "in", "out", "speed", "pitch_semitones", "formant"];
22
22
  readonly itemSourceHtmlV2: readonly ["kind", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params"];
23
23
  readonly itemSourceShapeV2: readonly ["kind", "shape", "params"];
@@ -4,7 +4,7 @@
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.ITEM_SOURCE_V2_KEYS_BY_DEFINITION = exports.ITEM_V2_KEYS_BY_DEFINITION = exports.SOURCE_KIND_V2 = exports.MOTION_FILE_V0_KEYS = exports.ANIMATOR_V0_KEYS = exports.MOTION_V0_KEYS = exports.KEYFRAME_V2_KEYS = exports.ITEM_SOURCE_V2_KEYS = exports.ITEM_V2_KEYS = void 0;
6
6
  exports.ITEM_V2_KEYS = ["id", "name", "hidden", "locked", "at", "duration", "anchor", "transform", "opacity", "blend", "crop", "adjust", "perspective", "motion", "animator", "keyframes", "items", "mask", "source", "role", "gain_db", "denoise", "lowcut_hz", "fade_in", "fade_out", "ducking", "duck_db", "duck_attack", "duck_release", "script", "reading", "provenance"];
7
- exports.ITEM_SOURCE_V2_KEYS = ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "chroma_key", "pitch_semitones", "formant", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params", "shape", "preset", "baked", "from", "filter", "id"];
7
+ exports.ITEM_SOURCE_V2_KEYS = ["kind", "src", "in", "out", "framing", "transition_out", "freeze", "fx", "speed", "gain_db", "mute", "chroma_key", "pitch_semitones", "formant", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params", "shape", "preset", "baked", "from", "filter", "id"];
8
8
  exports.KEYFRAME_V2_KEYS = ["t", "transform", "crop", "perspective", "opacity", "gain_db", "animator", "easing"];
9
9
  exports.MOTION_V0_KEYS = ["in", "out", "loop"];
10
10
  exports.ANIMATOR_V0_KEYS = ["id", "basis", "shape", "start", "end", "offset", "randomize", "amount", "ease"];
@@ -204,6 +204,8 @@ exports.ITEM_SOURCE_V2_KEYS_BY_DEFINITION = {
204
204
  "freeze",
205
205
  "fx",
206
206
  "speed",
207
+ "gain_db",
208
+ "mute",
207
209
  "chroma_key"
208
210
  ],
209
211
  "itemSourceAudioMediaV2": [
@@ -962,6 +962,8 @@ function copyMediaSourceFields(source) {
962
962
  ...(source.freeze !== undefined ? { freeze: source.freeze } : {}),
963
963
  ...(source.fx !== undefined ? { fx: source.fx } : {}),
964
964
  ...(source.speed !== undefined ? { speed: source.speed } : {}),
965
+ ...(source.gain_db !== undefined ? { gain_db: source.gain_db } : {}),
966
+ ...(source.mute !== undefined ? { mute: source.mute } : {}),
965
967
  ...(source.chroma_key !== undefined ? { chroma_key: source.chroma_key } : {})
966
968
  };
967
969
  }
@@ -1437,6 +1437,7 @@ var AkariEditKernel = (() => {
1437
1437
  if (segment.kind !== "src" || segment.cutIndex === null) continue;
1438
1438
  const cut = normalizedCuts[segment.cutIndex];
1439
1439
  if (!cut || typeof cut.src !== "string" || !cut.src) continue;
1440
+ if (cut.mute === true) continue;
1440
1441
  const speed = finitePositive(cut.speed) ? cut.speed : 1;
1441
1442
  const segmentIn = typeof segment.in === "number" ? segment.in : cut.in;
1442
1443
  const cutTimelineStart = segment.outStart - (segmentIn - cut.in) / speed;
@@ -2018,7 +2019,9 @@ var AkariEditKernel = (() => {
2018
2019
  "freeze",
2019
2020
  "fx",
2020
2021
  "speed",
2021
- "chroma_key"
2022
+ "chroma_key",
2023
+ "gain_db",
2024
+ "mute"
2022
2025
  ]), path);
2023
2026
  requireText(value.src, `${path}.src`);
2024
2027
  if (!sourceIds.has(value.src)) throw invalid(`${path}.src`, `sources[].id \u306B\u5B58\u5728\u3057\u307E\u305B\u3093: ${value.src}`);
@@ -2030,6 +2033,8 @@ var AkariEditKernel = (() => {
2030
2033
  }
2031
2034
  if (hasOwn(value, "fx") && !Array.isArray(value.fx)) throw invalid(`${path}.fx`, "\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059");
2032
2035
  if (hasOwn(value, "speed")) requirePositiveNumber(value.speed, `${path}.speed`);
2036
+ if (hasOwn(value, "gain_db")) requireRange(value.gain_db, -60, 12, `${path}.gain_db`);
2037
+ if (hasOwn(value, "mute") && typeof value.mute !== "boolean") throw invalid(`${path}.mute`, "boolean \u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059");
2033
2038
  return;
2034
2039
  case "html":
2035
2040
  requireExactKeys(value, /* @__PURE__ */ new Set(["kind", "path", "part", "style", "text", "exclude", "derivedFrom", "vars", "params"]), path);
@@ -3254,6 +3259,8 @@ var AkariEditKernel = (() => {
3254
3259
  ...source.freeze !== void 0 ? { freeze: source.freeze } : {},
3255
3260
  ...source.fx !== void 0 ? { fx: source.fx } : {},
3256
3261
  ...source.speed !== void 0 ? { speed: source.speed } : {},
3262
+ ...source.gain_db !== void 0 ? { gain_db: source.gain_db } : {},
3263
+ ...source.mute !== void 0 ? { mute: source.mute } : {},
3257
3264
  ...source.chroma_key !== void 0 ? { chroma_key: source.chroma_key } : {}
3258
3265
  };
3259
3266
  }
@@ -236,3 +236,5 @@ frame-engine bundle は生成物です。`generated/frame-engine.js` を直接
236
236
  生フレーム hash は隔離した検証専用 module からだけ利用でき、実行時 readback trap とは同時に
237
237
  有効化できません。DOM frame 検証は隔離した texture sentinel を使い、選択した settle policy
238
238
  (`raf2-paint-event` または `sync-layout`)を receipt に記録します。
239
+
240
+ 宣言型 `data-akari-vgpu-scene` は同梱 vgpu 0.4.0 の WebGPU ランタイムで描き、描画直後の canvas を GPU 合成へ渡す。v0 は pure な fragment パス、先行パスの texture 入力、CSS `--vgpu-*` ツマミに対応する。ライブプレビューは辺あたり半解像度、GPU 書き出しは等倍で、配置と時刻を共有する。WebGPU の probe・device 障害は `auto` でも失敗として伝播し OSR へ落とさない。receipt の `gpu.vgpu` は使用時だけ追加する。詳しくは [vgpu v0 契約](../../docs/contract-2026-09-06-vgpu-layer-v0.md)。
@@ -258,3 +258,5 @@ The frame-engine bundle is generated. Do not edit `generated/frame-engine.js` di
258
258
  Frame hashing is available only through the isolated verification module and cannot be combined
259
259
  with the runtime readback trap. DOM frame verification uses an isolated texture sentinel and
260
260
  records the selected settle policy (`raf2-paint-event` or `sync-layout`) in the receipt.
261
+
262
+ Declarative `data-akari-vgpu-scene` overlays use the vendored vgpu 0.4.0 WebGPU runtime and transfer each freshly drawn canvas into the GPU compositor. v0 supports pure fragment passes, earlier-pass texture inputs, and CSS `--vgpu-*` uniforms. Live preview defaults to half resolution per edge; GPU export uses full resolution with the same placement and time. WebGPU probe/device failures are fatal, including in `auto` (no OSR fallback); `gpu.vgpu` is present in receipts only when used. See the [vgpu v0 contract](../../docs/contract-2026-09-06-vgpu-layer-v0.md) (Japanese).
@@ -85,7 +85,8 @@ export function buildAtempoChain(speed) {
85
85
 
86
86
  // Counting semaphore whose limit is supplied per acquisition: the active count is shared by
87
87
  // the whole process while each caller may state the ceiling it accepts. Waiters are woken in
88
- // FIFO order and the count is bumped synchronously on wake-up, so the ceiling never overshoots.
88
+ // FIFO order unless explicitly promoted or passed while probing. The count is bumped
89
+ // synchronously on wake-up, so the ceiling never overshoots.
89
90
  class Semaphore {
90
91
  #active = 0;
91
92
  #waiters = [];
@@ -121,16 +122,37 @@ class Semaphore {
121
122
  waiter.reject?.(new Error('semaphore reservation cancelled'));
122
123
  this.#drain();
123
124
  },
125
+ promote: () => {
126
+ if (waiter.state !== 'waiting') return false;
127
+ const index = this.#waiters.indexOf(waiter);
128
+ if (index <= 0) return false;
129
+ this.#waiters.splice(index, 1);
130
+ this.#waiters.unshift(waiter);
131
+ this.#drain();
132
+ return true;
133
+ },
134
+ hold: () => {
135
+ if (waiter.state !== 'waiting' || waiter.resolve) return;
136
+ waiter.hold = true;
137
+ this.#drain();
138
+ },
124
139
  };
125
140
  }
126
141
 
127
142
  #drain() {
128
143
  if (this.#waiters.length) {
129
- const waiter = this.#waiters[0];
130
- // A probe reserves only its FIFO position, not an active ffmpeg slot.
131
- // Later requests cannot pass it while it is still preparing to acquire.
132
- if (!waiter.resolve || this.#active >= waiter.limit) return;
133
- this.#waiters.shift();
144
+ let index = 0;
145
+ let held = 0;
146
+ // Only probing tickets opt in to holding one idle slot. Plain reservations
147
+ // remain FIFO barriers; several probing tickets together hold just one slot.
148
+ while (index < this.#waiters.length && !this.#waiters[index].resolve) {
149
+ if (!this.#waiters[index].hold) return;
150
+ held = 1;
151
+ index += 1;
152
+ }
153
+ const waiter = this.#waiters[index];
154
+ if (!waiter || this.#active + held >= waiter.limit) return;
155
+ this.#waiters.splice(index, 1);
134
156
  this.#active += 1;
135
157
  waiter.state = 'acquired';
136
158
  waiter.resolve();
@@ -635,6 +657,52 @@ async function generate(prepared, options) {
635
657
  const generating = new Map();
636
658
  const requested = new Map();
637
659
  const probing = new Map();
660
+ // Tickets are registered synchronously, then reserved in request order when work
661
+ // starts (or promotion is requested). This also permits immediate promotion after
662
+ // requestPreviewAudioSidecar returns, without starting a probe in that call.
663
+ const tickets = new Set();
664
+
665
+ function registerTicket(options, prepared) {
666
+ const ticket = { sourcePath: prepared.sourcePath, outputDirectory: prepared.outputDirectory,
667
+ key: prepared.key, limit: settingsFrom(options).concurrency.ffmpeg, slot: options.slot };
668
+ tickets.add(ticket);
669
+ return ticket;
670
+ }
671
+
672
+ function reserveTickets() {
673
+ for (const ticket of tickets) ticket.slot ??= ffmpegSlots.reserve(ticket.limit);
674
+ }
675
+
676
+ function discardTicket(ticket, cancel = true) {
677
+ try { if (cancel) ticket.slot?.cancel(); } finally { tickets.delete(ticket); }
678
+ }
679
+
680
+ // keys are bare sidecar keys (an optional .flac/.pcm suffix is accepted).
681
+ // sourcePaths identify probing requests and remain usable after probe handoff.
682
+ // Each actual move is reported in input order; the last move has highest priority.
683
+ export function promotePreviewAudioSidecars({ cacheDir, keys = [], sourcePaths = [] }) {
684
+ const outputDirectory = path.resolve(cacheDir, 'preview-audio');
685
+ const promoted = [];
686
+ const promote = (value, matches) => {
687
+ const matching = [...tickets].filter(ticket => ticket.outputDirectory === outputDirectory && matches(ticket));
688
+ if (!matching.length) return;
689
+ reserveTickets();
690
+ let moved = false;
691
+ for (const ticket of matching) if (ticket.slot.promote?.()) moved = true;
692
+ if (moved) promoted.push(value);
693
+ };
694
+ for (const key of keys) {
695
+ const bareKey = String(key).replace(/\.(?:flac|pcm)$/u, '');
696
+ promote(key, ticket => ticket.key === bareKey);
697
+ }
698
+ for (const sourcePath of sourcePaths) {
699
+ if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) continue;
700
+ const resolved = path.resolve(sourcePath);
701
+ promote(sourcePath, ticket => ticket.sourcePath === resolved);
702
+ }
703
+ return { promoted };
704
+ }
705
+
638
706
  const listeners = new Set();
639
707
  const FAILURE_RETRY_MS = 60000;
640
708
 
@@ -793,11 +861,15 @@ function requestSidecar(options) {
793
861
  const resolved = requestOptions(options);
794
862
  if (resolved.status) {
795
863
  if (!status.probe?.pending || probing.has(resolved.probePath)) return status;
864
+ const ticket = registerTicket(options, { sourcePath: path.resolve(options.sourcePath),
865
+ outputDirectory: path.dirname(resolved.probePath), key: null });
796
866
  // Start after returning the declaration. The map is populated before any work runs.
797
867
  const pending = new Promise(resolve => setImmediate(resolve)).then(async () => {
798
- const slot = ffmpegSlots.reserve(settingsFrom(options).concurrency.ffmpeg);
868
+ reserveTickets();
869
+ const { slot } = ticket;
799
870
  let handedOff = false;
800
871
  try {
872
+ slot.hold?.();
801
873
  const probe = await probePreviewAudioSourceAsync(options.sourcePath, options);
802
874
  if (probe.ok) {
803
875
  writeCacheJson(resolved.probePath, probe);
@@ -818,18 +890,20 @@ function requestSidecar(options) {
818
890
  reason: probe.reason, sourcePath: path.resolve(options.sourcePath) });
819
891
  }
820
892
  } finally {
821
- if (!handedOff) slot.cancel();
893
+ discardTicket(ticket, !handedOff);
822
894
  }
823
895
  }).catch(error => console.warn('[preview-audio] probe cache failed', error))
824
- .finally(() => probing.delete(resolved.probePath));
896
+ .finally(() => { tickets.delete(ticket); probing.delete(resolved.probePath); });
825
897
  probing.set(resolved.probePath, pending);
826
898
  return status;
827
899
  }
828
900
  if (status.state !== 'missing' && status.state !== 'legacy') return status;
829
901
  const prepared = prepare(resolved.options, {}, false);
902
+ const ticket = registerTicket(resolved.options, prepared);
830
903
  const pending = new Promise(resolve => setImmediate(resolve)).then(async () => {
904
+ reserveTickets();
831
905
  fs.rmSync(path.join(prepared.outputDirectory, `${prepared.key}.failed.json`), { force: true });
832
- const result = await ensurePreviewAudioSidecar(resolved.options);
906
+ const result = await ensurePreviewAudioSidecar({ ...resolved.options, slot: ticket.slot });
833
907
  let state = 'ready';
834
908
  if (!result.ok) {
835
909
  state = classifyPreviewAudioFailure(result.reason) === 'no-audio' ? 'no-audio' : 'failed';
@@ -842,9 +916,8 @@ function requestSidecar(options) {
842
916
  ...(result.ok ? { path: result.path, durationSec: result.durationSec } : { reason: result.reason }) };
843
917
  }).catch(error => ({ key: prepared.key, state: 'failed', sourcePath: prepared.sourcePath,
844
918
  reason: summarize(error?.message, 'preview audio cache failed') }))
845
- .finally(() => options.slot?.cancel())
919
+ .finally(() => { discardTicket(ticket); requested.delete(prepared.outputPath); })
846
920
  .then(event => {
847
- requested.delete(prepared.outputPath);
848
921
  emitSidecarEvent(event);
849
922
  });
850
923
  requested.set(prepared.outputPath, pending);
@@ -865,8 +938,10 @@ export function ensurePreviewAudioSidecar(options) {
865
938
  options.slot?.cancel();
866
939
  return joined;
867
940
  }
868
- const pending = generate(prepared, options)
869
- .finally(() => options.slot?.cancel())
941
+ const ticket = options.slot ? null : registerTicket(options, prepared);
942
+ if (ticket) reserveTickets();
943
+ const pending = generate(prepared, ticket ? { ...options, slot: ticket.slot } : options)
944
+ .finally(() => { if (ticket) discardTicket(ticket); else options.slot?.cancel(); })
870
945
  .catch(error => failure(prepared.outputPath, prepared.key, error));
871
946
  generating.set(prepared.outputPath, pending);
872
947
  void pending.finally(() => generating.delete(prepared.outputPath));
@@ -4,6 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import childProcess from 'node:child_process';
7
+ import { EventEmitter } from 'node:events';
8
+ import { PassThrough } from 'node:stream';
7
9
  import { syncBuiltinESMExports } from 'node:module';
8
10
  import { setImmediate as nextTurn, setTimeout as delay } from 'node:timers/promises';
9
11
  import test from 'node:test';
@@ -11,6 +13,7 @@ import { resolveFfmpeg, resolveFfprobe } from '../src/index.mjs';
11
13
  import {
12
14
  __testing, ensurePreviewAudioSidecar, previewAudioSidecarStatus,
13
15
  requestPreviewAudioSidecar, subscribePreviewAudioSidecarEvents,
16
+ promotePreviewAudioSidecars,
14
17
  } from '../src/preview-audio-sidecar.mjs';
15
18
 
16
19
  const { Semaphore } = __testing;
@@ -298,6 +301,58 @@ function realBinaries() {
298
301
 
299
302
  const binaries = realBinaries();
300
303
 
304
+ function mockSpawn(t, run) {
305
+ const observed = t.mock.method(childProcess, 'spawn', (command, args) => {
306
+ const child = new EventEmitter();
307
+ child.stdout = new PassThrough();
308
+ child.stderr = new PassThrough();
309
+ child.exitCode = null;
310
+ child.signalCode = null;
311
+ child.kill = () => { child.signalCode = 'SIGKILL'; child.emit('close', null, 'SIGKILL'); };
312
+ setImmediate(async () => {
313
+ try {
314
+ await run(child, command, args);
315
+ child.exitCode = 0;
316
+ child.emit('close', 0, null);
317
+ } catch (error) {
318
+ child.stderr.write(error.message);
319
+ child.exitCode = 1;
320
+ child.emit('close', 1, null);
321
+ }
322
+ });
323
+ return child;
324
+ });
325
+ syncBuiltinESMExports();
326
+ t.after(() => { observed.mock.restore(); syncBuiltinESMExports(); });
327
+ return observed;
328
+ }
329
+
330
+ test('短 SFX の probe キャッシュは request と status の再取得で ffprobe を起動しない',
331
+ { timeout: 10000 }, async t => {
332
+ const f = fixture(t);
333
+ const observedSpawn = mockSpawn(t, async child => {
334
+ child.stdout.write(JSON.stringify({ streams: [{ sample_rate: '48000', channels: 2 }],
335
+ format: { duration: '0.1' } }));
336
+ });
337
+ const event = deferred();
338
+ t.after(subscribePreviewAudioSidecarEvents(value => event.resolve(value)));
339
+ const options = { ...f.options, decodedBytesThreshold: 64 * 1024 * 1024 };
340
+ const first = requestPreviewAudioSidecar(options);
341
+ assert.equal(first.probe.pending, true);
342
+ assert.equal((await event.promise).state, 'not-needed');
343
+ await nextTurn();
344
+ assert.equal(observedSpawn.mock.callCount(), 1);
345
+ assert.equal(observedSpawn.mock.calls[0].arguments[0], options.ffprobe);
346
+ const cached = JSON.parse(fs.readFileSync(path.join(f.directory, `probe-${first.probe.fingerprint}.json`), 'utf8'));
347
+ assert.ok(cached.durationSec > 0 && cached.durationSec < 1);
348
+ for (const read of [requestPreviewAudioSidecar, previewAudioSidecarStatus]) {
349
+ assert.deepEqual(read(options), { state: 'not-needed', key: null,
350
+ probe: { fingerprint: first.probe.fingerprint } });
351
+ }
352
+ await nextTurn();
353
+ assert.equal(observedSpawn.mock.callCount(), 1, '2 回目はキャッシュを読み spawn を増やさない');
354
+ });
355
+
301
356
  test('実 ffmpeg: 120 秒 WAV の A(probe)→ B → C は A が最初に ready になる',
302
357
  { skip: binaries.skip, timeout: 60000 }, async t => {
303
358
  const f = fixture(t);
@@ -328,7 +383,7 @@ test('実 ffmpeg: 120 秒 WAV の A(probe)→ B → C は A が最初に rea
328
383
  }
329
384
  });
330
385
 
331
- test('実 ffmpeg: 遅い probe の A と既知尺 B・C は既定 2 枠で要求順に起動する',
386
+ test('実 ffmpeg: 遅い probe の A は既定 2 枠の 1 枠を予約し B A → C で起動する',
332
387
  { skip: binaries.skip, timeout: 60000 }, async t => {
333
388
  const f = fixture(t);
334
389
  const sources = ['a-narration.wav', 'b-speech1.wav', 'c-speech2.wav'].map(name => path.join(f.root, name));
@@ -351,7 +406,13 @@ test('実 ffmpeg: 遅い probe の A と既知尺 B・C は既定 2 枠で要求
351
406
  });
352
407
  syncBuiltinESMExports();
353
408
  t.after(() => { observedSpawn.mock.restore(); syncBuiltinESMExports(); });
354
- const reservations = observeReservations(t);
409
+ const reservations = [];
410
+ const originalReserve = Semaphore.prototype.reserve;
411
+ t.mock.method(Semaphore.prototype, 'reserve', function (...args) {
412
+ const slot = originalReserve.apply(this, args);
413
+ reservations.push(slot);
414
+ return slot;
415
+ });
355
416
  const events = [];
356
417
  t.after(subscribePreviewAudioSidecarEvents(event => events.push(event)));
357
418
  const probe = deferred();
@@ -360,15 +421,255 @@ test('実 ffmpeg: 遅い probe の A と既知尺 B・C は既定 2 枠で要求
360
421
  probeAudio: () => probe.promise });
361
422
  for (const sourcePath of sources.slice(1)) requestPreviewAudioSidecar({ ...common, sourcePath, outSec: 120 });
362
423
  let deadline = Date.now() + 10000;
363
- while (reservations.length < 3 && Date.now() < deadline) await delay(10);
424
+ while (starts.length < 1 && Date.now() < deadline) await delay(1);
364
425
  const startsDuringProbe = [...starts];
365
426
  const reservationsDuringProbe = reservations.length;
366
427
  probe.resolve(metadata);
367
428
  deadline = Date.now() + 30000;
368
429
  while (events.length < 3 && Date.now() < deadline) await delay(10);
369
430
  assert.equal(reservationsDuringProbe, 3);
370
- assert.deepEqual(startsDuringProbe, [], 'probe は枠を占有せず順番だけを保持する');
371
- assert.deepEqual(starts, sources);
431
+ assert.deepEqual(startsDuringProbe, [sources[1]], 'probe は 1 枠だけ予約し B を通す');
432
+ assert.deepEqual(starts, [sources[1], sources[0], sources[2]]);
372
433
  assert.equal(peak, 2);
373
434
  assert.deepEqual(events.map(event => event.state), ['ready', 'ready', 'ready']);
374
435
  });
436
+
437
+ test('promote は待機札を先頭へ移し先頭・取得済み・破棄済みなら何もしない', async () => {
438
+ const semaphore = new Semaphore();
439
+ await semaphore.acquire(1);
440
+ const slots = [semaphore.reserve(1), semaphore.reserve(1), semaphore.reserve(1)];
441
+ const order = [];
442
+ const jobs = slots.map((slot, index) => slot.acquire().then(() => order.push(index)));
443
+ assert.equal(slots[0].promote(), false);
444
+ assert.equal(slots[2].promote(), true);
445
+ assert.equal(slots[2].promote(), false);
446
+ semaphore.release();
447
+ await jobs[2];
448
+ assert.equal(slots[2].promote(), false);
449
+ semaphore.release();
450
+ await jobs[0];
451
+ semaphore.release();
452
+ await jobs[1];
453
+ semaphore.release();
454
+ assert.deepEqual(order, [2, 0, 1]);
455
+ const cancelled = semaphore.reserve(1);
456
+ cancelled.cancel();
457
+ assert.equal(cancelled.promote(), false);
458
+ });
459
+
460
+ test('hold は 2 枠で B だけを通し probe 完了後は A が C より先に取得する', async () => {
461
+ const semaphore = new Semaphore();
462
+ const a = semaphore.reserve(2);
463
+ a.hold();
464
+ a.hold();
465
+ const order = [];
466
+ const b = semaphore.acquire(2).then(() => order.push('B'));
467
+ const c = semaphore.acquire(2).then(() => order.push('C'));
468
+ await b;
469
+ await nextTurn();
470
+ assert.deepEqual(order, ['B']);
471
+ await a.acquire().then(() => order.push('A'));
472
+ await nextTurn();
473
+ assert.deepEqual(order, ['B', 'A']);
474
+ a.hold();
475
+ semaphore.release();
476
+ await c;
477
+ assert.deepEqual(order, ['B', 'A', 'C']);
478
+ semaphore.release();
479
+ semaphore.release();
480
+ });
481
+
482
+ test('hold は 1 枠なら probe 中の B と C を待たせる', async () => {
483
+ const semaphore = new Semaphore();
484
+ const a = semaphore.reserve(1);
485
+ a.hold();
486
+ const order = [];
487
+ const b = semaphore.acquire(1).then(() => order.push('B'));
488
+ const c = semaphore.acquire(1).then(() => order.push('C'));
489
+ await nextTurn();
490
+ assert.deepEqual(order, []);
491
+ await a.acquire().then(() => order.push('A'));
492
+ assert.deepEqual(order, ['A']);
493
+ semaphore.release();
494
+ await b;
495
+ semaphore.release();
496
+ await c;
497
+ semaphore.release();
498
+ assert.deepEqual(order, ['A', 'B', 'C']);
499
+ });
500
+
501
+ test('複数の hold は合計 1 枠だけを予約し通常札の FIFO 障壁は維持する', async () => {
502
+ const semaphore = new Semaphore();
503
+ const a = semaphore.reserve(2);
504
+ const b = semaphore.reserve(2);
505
+ const barrier = semaphore.reserve(2);
506
+ a.hold();
507
+ b.hold();
508
+ const order = [];
509
+ const c = semaphore.acquire(2).then(() => order.push('C'));
510
+ const d = semaphore.acquire(2).then(() => order.push('D'));
511
+ await nextTurn();
512
+ assert.deepEqual(order, []);
513
+ barrier.cancel();
514
+ await c;
515
+ await nextTurn();
516
+ assert.deepEqual(order, ['C']);
517
+ a.cancel();
518
+ await nextTurn();
519
+ assert.deepEqual(order, ['C'], '残った probe も 1 枠を予約する');
520
+ b.cancel();
521
+ await d;
522
+ semaphore.release();
523
+ semaphore.release();
524
+ });
525
+
526
+ for (const by of ['keys', 'sourcePaths']) {
527
+ test(`昇格 API は ${by} で 3 番目を先頭にして完了順を 3 → 1 → 2 にする`,
528
+ { timeout: 5000 }, async t => {
529
+ const f = fixture(t);
530
+ const sources = ['first.wav', 'second.wav', 'third.wav'].map(name => path.join(f.root, name));
531
+ for (const sourcePath of sources) fs.writeFileSync(sourcePath, 'fixture');
532
+ const starts = [];
533
+ mockSpawn(t, async (child, command, args) => {
534
+ starts.push(args[args.indexOf('-i') + 1]);
535
+ fs.writeFileSync(args.at(-1), Buffer.alloc(480));
536
+ });
537
+ const events = [];
538
+ const done = deferred();
539
+ t.after(subscribePreviewAudioSidecarEvents(event => {
540
+ events.push(event);
541
+ if (events.length === 3) done.resolve();
542
+ }));
543
+ const probe = deferred();
544
+ const probing = deferred();
545
+ let probeCount = 0;
546
+ t.after(() => probe.resolve(metadata));
547
+ const common = { ...f.options, ffmpeg: 'mock-ffmpeg', probeAudio: () => {
548
+ if (++probeCount === 3) probing.resolve();
549
+ return probe.promise;
550
+ } };
551
+ const statuses = sources.map(sourcePath => requestPreviewAudioSidecar({ ...common, sourcePath,
552
+ ...(by === 'keys' ? { outSec: 120 } : { decodedBytesThreshold: 1 }) }));
553
+ const target = by === 'keys' ? statuses[2].key : sources[2];
554
+ if (by === 'sourcePaths') await probing.promise;
555
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: path.join(f.root, 'other'), [by]: [target] }),
556
+ { promoted: [] });
557
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root, [by]: [target, target] }),
558
+ { promoted: [target] });
559
+ probe.resolve(metadata);
560
+ await done.promise;
561
+ assert.deepEqual(events.map(event => event.state), ['ready', 'ready', 'ready']);
562
+ assert.deepEqual(events.map(event => event.sourcePath), [sources[2], sources[0], sources[1]]);
563
+ assert.deepEqual(starts, [sources[2], sources[0], sources[1]]);
564
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root,
565
+ keys: [events[0].key, 'missing'], sourcePaths: sources }), { promoted: [] });
566
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root }), { promoted: [] });
567
+ });
568
+ }
569
+
570
+ test('昇格 API は keys → sourcePaths の入力順に移動し取得済み札を無視する',
571
+ { timeout: 5000 }, async t => {
572
+ const f = fixture(t);
573
+ const sources = ['first.wav', 'second.wav', 'third.wav'].map(name => path.join(f.root, name));
574
+ for (const sourcePath of sources) fs.writeFileSync(sourcePath, 'fixture');
575
+ const entered = deferred();
576
+ const gate = deferred();
577
+ t.after(() => gate.resolve());
578
+ const starts = [];
579
+ mockSpawn(t, async (child, command, args) => {
580
+ starts.push(args[args.indexOf('-i') + 1]);
581
+ entered.resolve();
582
+ await gate.promise;
583
+ fs.writeFileSync(args.at(-1), Buffer.alloc(480));
584
+ });
585
+ const events = [];
586
+ const done = deferred();
587
+ t.after(subscribePreviewAudioSidecarEvents(event => {
588
+ events.push(event);
589
+ if (events.length === 3) done.resolve();
590
+ }));
591
+ const statuses = sources.map(sourcePath => requestPreviewAudioSidecar({ ...f.known, sourcePath,
592
+ ffmpeg: 'mock-ffmpeg' }));
593
+ const keys = [statuses[1].key, statuses[2].key];
594
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root, keys, sourcePaths: [sources[0]] }),
595
+ { promoted: [...keys, sources[0]] });
596
+ await entered.promise;
597
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root,
598
+ keys: [statuses[0].key], sourcePaths: [sources[0]] }), { promoted: [] });
599
+ gate.resolve();
600
+ await done.promise;
601
+ assert.deepEqual(starts, [sources[0], sources[2], sources[1]]);
602
+ assert.deepEqual(events.map(event => event.state), ['ready', 'ready', 'ready']);
603
+ });
604
+
605
+ test('背景 probe の hold は B だけを通し A → C へ枠を渡す',
606
+ { timeout: 5000 }, async t => {
607
+ const f = fixture(t);
608
+ const sources = ['first.wav', 'second.wav', 'third.wav'].map(name => path.join(f.root, name));
609
+ for (const sourcePath of sources) fs.writeFileSync(sourcePath, 'fixture');
610
+ const gates = sources.map(() => deferred());
611
+ const entered = sources.map(() => deferred());
612
+ const probe = deferred();
613
+ t.after(() => { probe.resolve(metadata); for (const gate of gates) gate.resolve(); });
614
+ const starts = [];
615
+ let active = 0;
616
+ let peak = 0;
617
+ mockSpawn(t, async (child, command, args) => {
618
+ const sourcePath = args[args.indexOf('-i') + 1];
619
+ const index = sources.indexOf(sourcePath);
620
+ starts.push(sourcePath);
621
+ peak = Math.max(peak, ++active);
622
+ entered[index].resolve();
623
+ await gates[index].promise;
624
+ fs.writeFileSync(args.at(-1), Buffer.alloc(480));
625
+ active -= 1;
626
+ });
627
+ const events = [];
628
+ const done = deferred();
629
+ t.after(subscribePreviewAudioSidecarEvents(event => {
630
+ events.push(event);
631
+ if (events.length === 3) done.resolve();
632
+ }));
633
+ const common = { ...f.options, concurrency: undefined, ffmpeg: 'mock-ffmpeg' };
634
+ requestPreviewAudioSidecar({ ...common, sourcePath: sources[0],
635
+ decodedBytesThreshold: 1, probeAudio: () => probe.promise });
636
+ for (const sourcePath of sources.slice(1)) requestPreviewAudioSidecar({ ...common, sourcePath, outSec: 120 });
637
+ await entered[1].promise;
638
+ await nextTurn();
639
+ assert.deepEqual(starts, [sources[1]]);
640
+ probe.resolve(metadata);
641
+ await entered[0].promise;
642
+ await nextTurn();
643
+ assert.deepEqual(starts, [sources[1], sources[0]]);
644
+ gates[0].resolve();
645
+ await entered[2].promise;
646
+ assert.deepEqual(starts, [sources[1], sources[0], sources[2]]);
647
+ gates[1].resolve();
648
+ gates[2].resolve();
649
+ await done.promise;
650
+ assert.equal(peak, 2);
651
+ assert.deepEqual(events.map(event => event.state), ['ready', 'ready', 'ready']);
652
+ });
653
+
654
+ test('昇格 API は ensure の待機札も動かし失敗・完了後は台帳から外す',
655
+ { timeout: 5000 }, async t => {
656
+ const f = fixture(t);
657
+ const sources = ['first.wav', 'second.wav', 'third.wav'].map(name => path.join(f.root, name));
658
+ for (const sourcePath of sources) fs.writeFileSync(sourcePath, 'fixture');
659
+ mockSpawn(t, async (child, command, args) => {
660
+ if (args[args.indexOf('-i') + 1] === sources[2]) throw new Error('test generation failure');
661
+ fs.writeFileSync(args.at(-1), Buffer.alloc(480));
662
+ });
663
+ const options = sources.map(sourcePath => ({ ...f.known, sourcePath, ffmpeg: 'mock-ffmpeg' }));
664
+ const keys = options.map(value => previewAudioSidecarStatus(value).key);
665
+ const order = [];
666
+ const jobs = options.map((value, index) => ensurePreviewAudioSidecar(value).then(result => {
667
+ order.push(index);
668
+ return result;
669
+ }));
670
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root, keys: [keys[2]] }), { promoted: [keys[2]] });
671
+ const results = await Promise.all(jobs);
672
+ assert.deepEqual(order, [2, 0, 1]);
673
+ assert.deepEqual(results.map(result => result.ok), [true, true, false]);
674
+ assert.deepEqual(promotePreviewAudioSidecars({ cacheDir: f.root, keys, sourcePaths: sources }), { promoted: [] });
675
+ });
@@ -65,6 +65,15 @@ Three.js + glTF シーンを決定的な時刻で描画し(`three-runtime.js`
65
65
  (`summary` = `EditSummary`。下記参照)。以降はタイムライン更新のたびに
66
66
  `window.akari.runtime.tick(t, playing)` を呼ぶ
67
67
 
68
+ vgpu overlay を扱うホストは、three 群がある場合はその後に
69
+ `src/vendor/vgpu-bundle.js` → `src/vgpu-runtime.js` の順で読み込む。
70
+ 書き出しは `await window.akari.vgpuRuntime.probe()` 後、container ごとに
71
+ `render(container, localTimeSeconds)` → canvas の登録/更新を同じ同期処理内で行う。
72
+ プレビューは `render(container, localTimeSeconds, { previewScale: 0.5 })`、非表示化と unmount で
73
+ `dispose(container)` を呼ぶ。probe と render の失敗を捕捉して任意の `[data-akari-vgpu-fallback]` を表示し、
74
+ 警告は 1 回にまとめる。共有 device は container の破棄時には落とさない。
75
+ 宣言と純関数・解像度の約束は [vgpu v0 契約](../../docs/contract-2026-09-06-vgpu-layer-v0.md) を参照。
76
+
68
77
  ## ホストアダプタ契約(新シェル実装者向け — 本パッケージへの入力)
69
78
 
70
79
  本パッケージの各スクリプトは `window.akari` 名前空間の以下のプロパティを
@@ -240,6 +249,8 @@ Web UI(preview-server)は `slot-params.js` を `/slot-params.js` で配り
240
249
  src/
241
250
  vendor/three-bundle.js Three.js core + GLTFLoader + RoomEnvironment の単一 IIFE
242
251
  vendor/three-LICENSE.txt Three.js の MIT License
252
+ vendor/vgpu-bundle.js vgpu 0.4.0 browser entry の単一 IIFE
253
+ vendor/vgpu-LICENSE.txt vgpu の MIT License
243
254
  vendor/vendor-3d-text-bundle.js troika-three-text + opentype.js + matter-js + poly-decomp の単一 IIFE
244
255
  vendor/troika-three-text-LICENSE.txt troika-three-text の MIT License
245
256
  vendor/opentype.js-LICENSE.txt opentype.js の MIT License
@@ -502,3 +513,52 @@ npm グローバルインストール禁止の制約内で完結するよう、
502
513
  231 行で区切ると `#minimap-viewport` 規則の宣言途中で切れ、閉じ括弧を欠く
503
514
  不正な CSS になるため、意図(「minimap 用ブロックを抽出」)に沿って実際の
504
515
  ブロック終端まで抽出した。詳細は `out/status.json` 隣接の `report.md` を参照
516
+
517
+ ## vgpu vendor の固定と再生成
518
+
519
+ `src/vendor/vgpu-bundle.js` は `vgpu@0.4.0` の browser entry を `esbuild@0.24.2` で
520
+ 単一 IIFE にしたもの。実行時に npm/CDN/外部 origin を参照しない。
521
+ 取得した tarball の integrity は以下に固定する。
522
+
523
+ - `vgpu@0.4.0`: `sha512-U8ofyb2FdiLpbkMJrGSu2PWu/fmiKsXYYpp3agFE3TjM5+6o7aZFD1PBl8XxxnSeN7br/Izmow7fcEmYJTpxSw==`
524
+ - `@vgpu/core@0.4.0`: `sha512-yBkqlNzTRYaRRzOAsootOmppzsDZytL5Xe09rZfV60zJc4FTr5jWZmGCrm/zlWM/1wEKocDtKloVKfKD+2TNPw==`
525
+ - `@vgpu/wgsl@0.4.0`: `sha512-19aESov4jNukukAlbE9VH34T/2VcIzmQPZ45WyphtrUMevFacAqTVU6qJVAP/YGX3rjQaL7Eq4jdvEhtOxNEPg==`
526
+ - `@vgpu/wgsl-std@0.4.0`: `sha512-a0EkK1y5VeA1jCEjPxe7BW7zTebL1UKLIQlEdxqrb8T1Zr+xO7VBe5mTNsuJtKWxgF+lANUqLwScyN+Fs4ooFw==`
527
+ - `@vgpu/adapter-mock@0.4.0`: `sha512-fqOEHCZAt+mMyOpgAA5inFWSoB9siO1LQIq9t+i6iemLbp2VBEEWlhZXCh3Hf+UDqbkut8kQaDrMPLt2J9LpvA==`
528
+ - `esbuild@0.24.2`: `sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==`
529
+ - `@esbuild/darwin-arm64@0.24.2`: `sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==`
530
+
531
+ entry `akari-vgpu-entry.js`:
532
+
533
+ ```js
534
+ import { init, effect, surface, target, frame, sampler, uniforms, clock, VGPUError } from "vgpu";
535
+ window.AkariVgpu = Object.freeze({ init, effect, surface, target, frame, sampler, uniforms, clock, VGPUError });
536
+ ```
537
+
538
+ 再生成(空の一時ディレクトリ、macOS arm64。リポの package.json / lock は変更しない):
539
+
540
+ ```sh
541
+ npm pack vgpu@0.4.0 @vgpu/core@0.4.0 @vgpu/wgsl@0.4.0 @vgpu/wgsl-std@0.4.0 @vgpu/adapter-mock@0.4.0 --ignore-scripts
542
+ npm pack esbuild@0.24.2 @esbuild/darwin-arm64@0.24.2 --ignore-scripts
543
+ mkdir -p node_modules/vgpu node_modules/@vgpu/core node_modules/@vgpu/wgsl node_modules/@vgpu/wgsl-std node_modules/@vgpu/adapter-mock node_modules/esbuild node_modules/@esbuild/darwin-arm64
544
+ tar -xzf vgpu-0.4.0.tgz -C node_modules/vgpu --strip-components=1
545
+ tar -xzf vgpu-core-0.4.0.tgz -C node_modules/@vgpu/core --strip-components=1
546
+ tar -xzf vgpu-wgsl-0.4.0.tgz -C node_modules/@vgpu/wgsl --strip-components=1
547
+ tar -xzf vgpu-wgsl-std-0.4.0.tgz -C node_modules/@vgpu/wgsl-std --strip-components=1
548
+ tar -xzf vgpu-adapter-mock-0.4.0.tgz -C node_modules/@vgpu/adapter-mock --strip-components=1
549
+ tar -xzf esbuild-0.24.2.tgz -C node_modules/esbuild --strip-components=1
550
+ tar -xzf esbuild-darwin-arm64-0.24.2.tgz -C node_modules/@esbuild/darwin-arm64 --strip-components=1
551
+ node node_modules/esbuild/bin/esbuild akari-vgpu-entry.js \
552
+ --bundle --format=iife --platform=browser --target=es2020 --minify \
553
+ --legal-comments=inline --metafile=meta.json --outfile=vgpu-bundle.js
554
+ cp node_modules/vgpu/LICENSE vgpu-LICENSE.txt
555
+ grep -cE '@modelcontextprotocol/server|zod|pngjs|pixelmatch|@vgpu/adapter-node|@vgpu/adapter-mock' vgpu-bundle.js || true
556
+ wc -c vgpu-bundle.js
557
+ shasum -a 256 vgpu-bundle.js
558
+ ```
559
+
560
+ 実測: **152953 bytes** / SHA-256 `9251bb41157e72e36211af2d0766de72db7251023fb2b968c15bdc529c111c25`。
561
+ metafile の npm 入力は `vgpu` / `@vgpu/core` / `@vgpu/wgsl` の 3 パッケージのみ。
562
+ 上記 grep は **0 件**。`vgpu/node` / `vgpu/three` / `vgpu/mock` の entry は使用しない。
563
+ 唯一の URL 文字列 `https://github.com/vercel-labs/vgpu/issues/294` はエラーメッセージの参照先で、実行時 fetch ではない。
564
+ MIT License(Copyright (c) 2025 Vercel, Inc.)全文は `src/vendor/vgpu-LICENSE.txt` に保持する。
@@ -1151,9 +1151,11 @@
1151
1151
  "freeze": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/cutFreeze" }] },
1152
1152
  "fx": { "type": "array", "items": { "$ref": "#/$defs/cutFx" } },
1153
1153
  "speed": { "$ref": "#/$defs/positiveNumber" },
1154
+ "gain_db": { "type": "number", "minimum": -60, "maximum": 12 },
1155
+ "mute": { "type": "boolean" },
1154
1156
  "chroma_key": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/chromaKey" }] }
1155
1157
  },
1156
- "$comment": "notes-2026-08-18-timeline-latency-and-track-model.md §9 / §10-1。src は editV2.sources[].id を参照し、in/out は素材内の秒。参照整合と out > in は兄弟値比較のため reader / edit-lint が検証する。"
1158
+ "$comment": "notes-2026-08-18-timeline-latency-and-track-model.md §9 / §10-1。src は editV2.sources[].id を参照し、in/out は素材内の秒。参照整合と out > in は兄弟値比較のため reader / edit-lint が検証する。gain_db / mute は埋め込み音声(speech)の減衰 / ミュートで、書き出しとプレビューで同値。省略時は gain 0・鳴る。"
1157
1159
  },
1158
1160
  "itemSourceAudioMediaV2": {
1159
1161
  "type": "object",
@@ -77,6 +77,8 @@
77
77
  { "path": "tracks[].items[].source.fx", "applies_to": ["cuts", "layers"], "gpu": "ignored", "osr": "ignored", "runtime_warning": true, "evidence": "packages/edit-store/src/internal-model.ts copyMediaSourceFields; packages/frame-engine/src/timeline/plan.ts KNOWN_CUT_KEYS/KNOWN_LAYER_KEYS omit fx", "hint": "GPU / OSR frame-engine には登録済み cut FX が無い" },
78
78
  { "path": "tracks[].items[].source.speed", "applies_to": ["cuts"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem speed projection; packages/frame-engine/src/timeline/plan.ts buildResolvedTimelinePlan/layerFromPlacement" },
79
79
  { "path": "tracks[].items[].source.speed", "applies_to": ["layers"], "gpu": "ignored", "osr": "ignored", "runtime_warning": true, "evidence": "packages/edit-store/src/internal-model.ts copyMediaSourceFields; packages/frame-engine/src/timeline/plan.ts KNOWN_LAYER_KEYS omits speed" },
80
+ { "path": "tracks[].items[].source.gain_db", "applies_to": ["cuts"], "gpu": "other-subsystem", "osr": "other-subsystem", "evidence": "packages/render-cut/src/plan.mjs cutSpeechVolumeSuffix (appendSequentialAudioCutFilters / gap-aware); packages/edit-store/src/audio-schedule.ts projectSpeechDeclarations speechGainDb", "note": "埋め込み音声(speech)の減衰。映像エンジンではなく render-cut の cut 音声(全エンジン共通)とプレビューの音声供給が消費する" },
81
+ { "path": "tracks[].items[].source.mute", "applies_to": ["cuts"], "gpu": "other-subsystem", "osr": "other-subsystem", "evidence": "packages/render-cut/src/plan.mjs cut.mute -> anullsrc branch; packages/edit-store/src/audio-schedule.ts projectSpeechDeclarations skips muted cuts", "note": "埋め込み音声(speech)のミュート。尺は保ち無音区間になる" },
80
82
  { "path": "tracks[].items[].source.chroma_key", "applies_to": ["cuts", "layers"], "gpu": "partial", "osr": "partial", "runtime_warning": true, "evidence": "packages/edit-store/src/internal-model.ts needsLayersEngine/copyMediaSourceFields; packages/frame-engine/src/timeline/plan.ts KNOWN_CUT_KEYS/KNOWN_LAYER_KEYS omit chroma_key", "note": "透過 intent は layers 振り分けに使うが、frame-engine 自体は chroma key 抽出や background 置換をしない" },
81
83
  { "path": "tracks[].items[].source.path", "applies_to": ["overlays"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem html source; packages/gpu-export/src/page-builder.mjs loadAndBuildGpuPage; packages/osr-export/src/page-builder.mjs loadAndBuildOsrPage" },
82
84
  { "path": "tracks[].items[].source.path", "applies_to": ["captions"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem fixes captions.json; packages/gpu-export/src/page-builder.mjs loadAndBuildGpuPage; packages/osr-export/src/page-builder.mjs loadAndBuildOsrPage", "note": "schema が captions.json の定数に閉じている" },
@@ -15,6 +15,18 @@ function fixture(name) {
15
15
  return JSON.parse(readFileSync(join(examplesRoot, name, "edit.json"), "utf8"));
16
16
  }
17
17
 
18
+ test("visual media source validates embedded speech gain and mute", () => {
19
+ const value = fixture("edit-v2-valid");
20
+ Object.assign(value.tracks[3].items[0].source, { gain_db: -12, mute: true });
21
+ assert.equal(validate(value), true, JSON.stringify(validate.errors));
22
+ for (const [key, invalid] of [['gain_db', -61], ['gain_db', 13], ['gain_db', '0'], ['mute', 'true']]) {
23
+ const rejected = structuredClone(value);
24
+ rejected.tracks[3].items[0].source[key] = invalid;
25
+ assert.equal(validate(rejected), false);
26
+ assert.ok(validate.errors.some(error => error.instancePath === `/tracks/3/items/0/source/${key}`));
27
+ }
28
+ });
29
+
18
30
  test("editV2 is the third root branch and keeps v2 timing/output definitions separate", () => {
19
31
  assert.equal(schema.$id, "urn:akari-video:schema:edit:v1");
20
32
  assert.match(schema.title, /v0\/v1\/v2/);
@@ -27,8 +27,8 @@ test("engine capability table declares the version, engines, and status vocabula
27
27
  assert.deepEqual(table.statuses, ["consumed", "partial", "ignored", "other-subsystem"]);
28
28
  });
29
29
 
30
- test("all 66 generated item, source, and keyframe keys have a capability row", () => {
31
- assert.equal(canonicalPaths.size, 66);
30
+ test("all 68 generated item, source, and keyframe keys have a capability row", () => {
31
+ assert.equal(canonicalPaths.size, 68);
32
32
  const covered = new Set(table.fields.map((field) => field.path));
33
33
  assert.deepEqual([...canonicalPaths].filter((path) => !covered.has(path)), []);
34
34
  });