akari-video 0.1.10 → 0.1.11
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/docs/contract-2026-07-22-render-basics.md +27 -1
- package/vendor/docs/contract-2026-07-23-analysis-person-matte.md +7 -0
- package/vendor/docs/contract-2026-08-02-preview-parity.md +17 -0
- package/vendor/docs/contract-2026-08-13-avatar-drive-v0.md +246 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/asset-resolver/src/paid-zip.mjs +6 -1
- package/vendor/packages/asset-resolver/src/resolve.mjs +28 -6
- package/vendor/packages/asset-resolver/test/resolve-paid-zip.test.mjs +93 -3
- package/vendor/packages/edit-lint/src/cut-timeline.mjs +17 -0
- package/vendor/packages/edit-lint/src/edit-lint.mjs +36 -3
- package/vendor/packages/schemas/bin/validate-edit.mjs +2 -2
- package/vendor/packages/schemas/edit.schema.json +2 -2
- package/vendor/packages/schemas/test/validate-edit.test.mjs +2 -2
- package/vendor/skills/edit-plan/execution.md +31 -0
- package/vendor/skills/overlay-authoring/3d.md +47 -1
- package/vendor/skills/overlay-authoring/motion.md +4 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|---|---|---|---|---|
|
|
18
18
|
| 1 | 定速変更(クリップ単位の倍速/スロー) | `cuts[].speed`(number・既定 1.0・v0 は定速のみ、ランプは将来) | `setpts` + `atempo`(>2x/<0.5x の段組み) | 出力尺が理論値と一致(ffprobe)・音程/同期の実聴確認 1 点 |
|
|
19
19
|
| 2 | クロマキー背景置換 | `source.chroma_key`: {color, similarity, blend, background(色 or 画像/動画パス)} | `chromakey`/`colorkey` + 背景入力の `overlay` | 緑背景フィクスチャで背景が置換された出力のピクセルサンプル検証 |
|
|
20
|
-
| 3 | 基本トランジション | `cuts[].transition_out`: {type: dissolve/fade-black/fade-white
|
|
20
|
+
| 3 | 基本トランジション | `cuts[].transition_out`: {type: dissolve/fade-black/fade-white/**reveal-down/reveal-up**, duration} | `xfade`(transition 指定があるカット境界のみ xfade 経路)。reveal 系は ffmpeg の `revealdown` / `revealup` | 境界フレームの中間ブレンド実在をフレーム抽出で確認・指定なし境界はハードカット維持。reveal 系は色が混ざらないため、遷移中間フレームの**上半分と下半分を別々に測って**前後カットが同居することを確認する |
|
|
21
21
|
| 4 | 色調フィルター(LUT) | `output.look`: {lut(プリセット参照 or パス), intensity} | `lut3d`(intensity は `blend` 併用) | LUT 有無 2 出力のフレームピクセル差分・プリセット表 `presets/luts/`(初期 2〜3 本。2026-07-29 に `catalog/luts/` から移設) |
|
|
22
22
|
| 5 | 音声マスター処理 | `audio.master`: {denoise(off/std/strong), loudnorm(target LUFS・既定 -14)} | `afftdn` / `loudnorm`(2 パスでなく 1 パス許容 v0) | 出力のラウドネス実測(ffmpeg ebur128)が目標 ±1LU |
|
|
23
23
|
| 6 | 画角操作(静的クロップ / ズームキーフレーム / 段階縮小) | `cuts[].framing`: `{crop?: {x,y,w,h}(0..1 の出力相対・静的), keyframes?: [{t,scale,cx?,cy?}](t=カット内秒・線形補間。2 点でズーム、3 点以上で段階縮小・cx/cy 省略時 0.5)}` | 出力キャンバスへフィット済みの frame を `crop` で窓抜きし `scale` で再拡大(punch-in)。静的 `crop` は `w/h/x/y` とも定数。ズームは `crop` 自身の `w/h` が実機検証で init 時一度しか評価されない制約があるため、`scale` 側を `eval=frame` で `scale(t)` 倍に広げ、`crop` は固定 `w=width:h=height` のまま `x/y` だけを `t` の関数で追わせる方式(詳細 §4-1) | 静的 crop は出力フレームの画素でクロップ位置が宣言どおりであることを実測・ズームは開始/中間/終端フレームで可視要素の実測サイズから逆算したスケールが線形補間の理論値と一致(±5%)・3 点キーフレームは 2 段階の縮小がフレーム抽出で確認できる |
|
|
@@ -31,6 +31,32 @@
|
|
|
31
31
|
edit-lint / fixtures / test を同時追随
|
|
32
32
|
2. プレビュー(preview-engine)は v0 では**近似不要・無視でよい**(出力最優先。
|
|
33
33
|
「プレビューは近似・書き出しが正」の哲学を全項目に適用。プレビュー追随は別契約)
|
|
34
|
+
3. **`output.look`(#4 の LUT)の適用範囲は `cuts[]` の本編映像だけ**である。
|
|
35
|
+
`layers[]`(PinP / 人物マット / B-roll)と `overlays[]` には**掛からない**。
|
|
36
|
+
同じ絵の一部として重ねる素材の色を本編に合わせたいときは、`layers[].filter`
|
|
37
|
+
(`{type:"lut", id, intensity}`。正本 = `contract-2026-08-12-region-filter-layer-v0.md` §4)
|
|
38
|
+
へ**同じ `id` / `intensity` を明示的に宣言する**。
|
|
39
|
+
実害例(2026-08-14・リール制作): 本編にだけ `cinematic` が乗り、重ねた人物切り抜きが
|
|
40
|
+
素の色のまま合成されて、窓の継ぎ目で肌色が食い違った。「プロジェクト全体の色」だと
|
|
41
|
+
誤解しやすいため、ここに明記する。
|
|
42
|
+
|
|
43
|
+
### 2-4. reveal 系トランジション(`reveal-down` / `reveal-up`。2026-08-14 追加)
|
|
44
|
+
|
|
45
|
+
**前カットが丸ごとその方向へ動いて画面外へ抜け、空いた側から次カットが現れる**
|
|
46
|
+
(前カットは動きながら画面端でクロップされる)。ディゾルブのように混ざらないので、
|
|
47
|
+
**同じ構図が続くトークシーンでも「場面が入れ替わった」ことが読める**のが採用理由
|
|
48
|
+
(オーナー指定 2026-08-14「テンプレの基本トランジションとして必要」)。
|
|
49
|
+
|
|
50
|
+
- `reveal-down` = 前カットが下へ降りる(画面上部から次カットが出てくる)
|
|
51
|
+
- `reveal-up` = 前カットが上へ抜ける(画面下部から次カットが出てくる)
|
|
52
|
+
- 実測(64x64・10fps・duration 1s・遷移中間 t=2.5s): `reveal-down` で上半分 RGB(0,0,253)=次カット /
|
|
53
|
+
下半分 RGB(252,0,0)=前カット。`reveal-up` はこの上下が入れ替わる
|
|
54
|
+
- **他の xfade と同じく、遷移の重なり分だけタイムラインが縮む**(境界 1 つにつき `duration` 秒)。
|
|
55
|
+
`layers[]` / `overlays[]` / `audio.sfx[]` を**タイムライン秒で手置き**しているプロジェクトでは、
|
|
56
|
+
トランジションを足すと後続の配置が全部ずれる。字幕は (`src`, source 秒) で書くのでエンジンが
|
|
57
|
+
追随するが、手置きの要素は自分で引き直す必要がある。尺を変えたくない場合は、
|
|
58
|
+
トランジションではなくオーバーレイで表現する(前カット最終フレームを焼いて動かす)という
|
|
59
|
+
逃げ道もあるが、静止画になるうえプロジェクト固有の焼き込みが要るので既定にはしない
|
|
34
60
|
|
|
35
61
|
## 3. 残裁定
|
|
36
62
|
|
|
@@ -121,6 +121,13 @@ HEVC alpha MOV は「Apple 系ツールへの受け渡しが要るとき」の
|
|
|
121
121
|
がそのまま適用される。`beats` / `emphasis_words` と同じ扱いである
|
|
122
122
|
- マットに `--ss` 相当のオフセットを持たせない。素材の途中区間だけを切り出したマットを
|
|
123
123
|
`person_matte` に載せない(載せると時刻 0 の一致が壊れる)
|
|
124
|
+
- 一方で、**カット単位に切り出したマットを `layers[].src` へ直接置く**運用(`analysis.json` を
|
|
125
|
+
経由しない、プロジェクト固有の `assets/matte/*.mov` など)は本契約の管轄外である。
|
|
126
|
+
その場合は本節の「時刻 0 一致」が成り立たないので、**切り出しの由来(元素材 / in / out /
|
|
127
|
+
speed / fps)を素材の隣に必ず残す**こと。由来が無いと消費側は素材の頭が何の時刻かを
|
|
128
|
+
推測するしかなく、十数フレーム単位でずれたまま気づけない(2026-08-14 に実害)。
|
|
129
|
+
運用上の注意は `docs/contract-2026-08-02-preview-parity.md` §2.4 と
|
|
130
|
+
`skills/edit-plan/execution.md`「レイヤー素材の時間基準」を参照
|
|
124
131
|
- 消費側は表示・書き出しのたびに `cuts[]` から timeline 秒へ射影する。射影結果を永続化しない。
|
|
125
132
|
同一 source 区間が複数回現れれば、1 本のマットが複数の timeline 位置へ射影される
|
|
126
133
|
- マットの尺が素材の尺より短い場合、超えた範囲は**マット無し**として扱う(エラーではない)。
|
|
@@ -57,6 +57,23 @@
|
|
|
57
57
|
### 2.4 レイヤー(B-roll)
|
|
58
58
|
- `t` 〜 `t + duration` の窓外では非表示。**初期状態も非表示**(窓に入るまで描画しない)
|
|
59
59
|
- 表示中は `currentTime` を出力時刻に同期する
|
|
60
|
+
- **素材内オフセット(in トリム)は無い。素材の先頭が常に `t` に対応する**。`duration` は
|
|
61
|
+
素材の先頭から何秒使うかであって、素材のどこを使うかは選べない(`cuts[].in/out` に相当する
|
|
62
|
+
ものが `layers[]` には存在しない)。素材の途中区間を重ねたいときは**素材そのものを切り出す**
|
|
63
|
+
必要がある。
|
|
64
|
+
- 切り出した素材は「何を・どこから・どの速度で切り出したか」が失われるため、
|
|
65
|
+
**由来(元素材 / in / out / speed / fps)を素材の隣に必ず残す**こと。残っていないと、
|
|
66
|
+
次に触る人(人間・AI とも)が「素材の頭が何の時刻なのか」を推測することになり、
|
|
67
|
+
十数フレーム単位でズレたまま気づけない。
|
|
68
|
+
- 実害例(2026-08-14・リール制作): カット単位に切り出した人物マットを「先行表示分の
|
|
69
|
+
プリロールを持っているはず」と**推測**して頭をトリムしたところ、実際は切り出し済みで
|
|
70
|
+
プリロールが無く、11〜23 フレームずれた。さらに `duration` を詰めた結果、区間の末尾で
|
|
71
|
+
マットが尽きて「人物が消えて背景だけ」になった。
|
|
72
|
+
- 素材とカットの時間対応を後から実測する場合、**フレーム差分の絶対値(`blend=difference`)は
|
|
73
|
+
使わない**。色調整(`output.look` は本編にしか掛からない = §2.4 冒頭の別項)で素材と本編の
|
|
74
|
+
色が違うと、その色差が支配して指標が平坦になり誤った結論を導く。**フレーム間差分エネルギーの
|
|
75
|
+
時系列(`tblend=all_mode=difference` → `signalstats` の YAVG)を正規化して相互相関**させると、
|
|
76
|
+
色に不変で lag を特定できる。
|
|
60
77
|
|
|
61
78
|
#### 2.4.1 空間クロップ(`layers[].crop`。2026-08-06 導入)
|
|
62
79
|
- `crop = { x, y, w, h }`(**0..1 正規化・ソースフレーム相対・静的**)。省略時は既定
|
|
@@ -256,3 +256,249 @@ track の head は yaw/pitch/roll の radian だが、`avatar-vrm` の drive 受
|
|
|
256
256
|
|
|
257
257
|
同じ track、cuts、fps、平滑化窓から作る head/eyes/emotion は決定論的であり、壁時計や乱数を
|
|
258
258
|
参照しない。
|
|
259
|
+
|
|
260
|
+
## v1.1 追記(2026-08-14): PNGTuber モーション
|
|
261
|
+
|
|
262
|
+
sprite ベイクへ、呼吸・発話バウンス・発話 onset ごとの微傾きを additive に追加する。
|
|
263
|
+
`--motion-intensity <0..1>` の既定値は `0.5`。`--no-motion` は intensity `0` の別名であり、
|
|
264
|
+
`--motion-intensity` との同時指定は曖昧さを避けるため拒否する。intensity `0` では全 frame が
|
|
265
|
+
`scaleX=scaleY=1, tx=ty=rotateDeg=0` の厳密な恒等変換となり、アフィン変換とキャンバス拡張を
|
|
266
|
+
一切通らない従来の raw RGBA → ProRes 経路を使う。
|
|
267
|
+
|
|
268
|
+
frame `f`、`t=f/fps`、intensity `I` とする。入力ハッシュから得た位相 `p0,p1` により、呼吸波を
|
|
269
|
+
次で定める。
|
|
270
|
+
|
|
271
|
+
```text
|
|
272
|
+
breath(t) = (sin(2π·0.25·t+p0) + 0.20·sin(2π·0.50·t+p1)) / 1.20
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
発話中は `mouth != "closed"` と判定する。発話 envelope `E` は target `1`(発話)/ `0`(無発話)
|
|
276
|
+
へ指数平滑し、時定数は attack `0.06 s`、release `0.12 s`。発話 onset frame `o` から
|
|
277
|
+
`pulse=(1-cos(2π·3.0·(f-o)/fps))/2`、`talk=E·(0.35+0.65·pulse)` とする。最終変換は次のとおり。
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
scaleX = 1
|
|
281
|
+
scaleY = 1 + I·(0.008·breath + 0.028·talk)
|
|
282
|
+
tx = 0
|
|
283
|
+
ty = -spriteHeight·I·(0.0015·breath + 0.009·talk)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
微傾きは closed → 発話への各 onset で入力 seed の PRNG から符号と大きさを引き、target を
|
|
287
|
+
`±3.2°·U(0.55,1.0)` とする。現在角度は target へ時定数 `0.28 s` の指数平滑で近づく。
|
|
288
|
+
`--expression-track` 併用時、該当 frame の `drive.head` が non-null なら手続き角度を使わず、
|
|
289
|
+
`rotateDeg=I·head.roll` とする。head が null の frame だけ手続き角度へ戻る。アフィン変換は拡張
|
|
290
|
+
キャンバス中心を基準に scale → rotate → translate の順で適用し、RGBA は premultiplied alpha の
|
|
291
|
+
bilinear 補間後に straight alpha へ戻す。
|
|
292
|
+
|
|
293
|
+
キャンバスの四辺には全 frame で同じ整数 margin `M` を加える。各 frame の
|
|
294
|
+
`θ=abs(rotateDeg)·π/180`、`hx=width·scaleX/2`、`hy=height·scaleY/2` に対し、
|
|
295
|
+
|
|
296
|
+
```text
|
|
297
|
+
ex = abs(cos θ)·hx + abs(sin θ)·hy + abs(tx)
|
|
298
|
+
ey = abs(sin θ)·hx + abs(cos θ)·hy + abs(ty)
|
|
299
|
+
M = ceil(max_all_frames(ex-width/2, ey-height/2) + 2px)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
とする。末尾の `2px` は bilinear sampling support である。出力寸法は
|
|
303
|
+
`(width+2M) × (height+2M)`。layer 配置には sprite.json の元寸法ではなくこの実ベイク寸法を使い、
|
|
304
|
+
明示座標用 anchor も `(M + anchor·元寸法) / 実ベイク寸法` へ写像する。
|
|
305
|
+
|
|
306
|
+
モーション seed は正規化済み edit.json、sprite.json、駆動 profile と固定識別子
|
|
307
|
+
`avatar-drive-motion-v1.1` を stable stringify した SHA-256 から導出する。位相、onset の傾き、
|
|
308
|
+
フレーム変換、補間、margin は壁時計・OS 乱数・mtime を参照しない。同一入力、同一 CLI 値、同一
|
|
309
|
+
ffmpeg 実装なら stdout と MOV は byte 単位で決定論的である。
|
|
310
|
+
|
|
311
|
+
既定が motion on (`0.5`) になったため、v1.1 の既定出力は従来よりキャンバスが大きく、画素も
|
|
312
|
+
アフィン補間後の値へ変わる後方非互換点がある。従来と同じ寸法・画素・ProRes 呼び出しを必要とする
|
|
313
|
+
場合は `--no-motion`(または `--motion-intensity 0`)を指定する。
|
|
314
|
+
|
|
315
|
+
## v2 追記(2026-08-14): 多層パーツツリーと 2D 物理
|
|
316
|
+
|
|
317
|
+
v2 は `sprite.json` を置き換えない。`--sprites <dir>` の直下に `sprite.json` があれば従来形式として
|
|
318
|
+
一切同じ経路で読み、無い場合だけ `parts.json` v2 を読む。両方がある場合も `sprite.json` を優先する。
|
|
319
|
+
したがって既存セットの manifest、状態列、RGBA 合成、ProRes 呼び出し、stdout は変更しない。
|
|
320
|
+
|
|
321
|
+
### v2.1 parts.json
|
|
322
|
+
|
|
323
|
+
```jsonc
|
|
324
|
+
{
|
|
325
|
+
"version": 2,
|
|
326
|
+
"size": { "width": 160, "height": 160 },
|
|
327
|
+
"anchor": { "x": 0.5, "y": 1 },
|
|
328
|
+
"parts": [
|
|
329
|
+
{
|
|
330
|
+
"id": "body",
|
|
331
|
+
"image": "body.png",
|
|
332
|
+
"parent": null,
|
|
333
|
+
"offset": { "x": 80, "y": 150 },
|
|
334
|
+
"origin": { "x": 35, "y": 70 },
|
|
335
|
+
"z": 0,
|
|
336
|
+
"states": "always"
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"id": "hair-left",
|
|
340
|
+
"image": "hair-left.png",
|
|
341
|
+
"parent": "head",
|
|
342
|
+
"offset": { "x": -31, "y": -29 },
|
|
343
|
+
"origin": { "x": 9, "y": 5 },
|
|
344
|
+
"z": 5,
|
|
345
|
+
"states": "always",
|
|
346
|
+
"physics": {
|
|
347
|
+
"wobble": { "x": { "amplitude": 2, "frequency": 0.48, "phase": 1.1 } },
|
|
348
|
+
"follow": { "drag": 6 },
|
|
349
|
+
"rotationalDrag": { "strength": 1.35, "minDeg": -18, "maxDeg": 18, "lerp": 0.25 }
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"id": "mouth-a",
|
|
354
|
+
"image": "mouth-a.png",
|
|
355
|
+
"parent": "head",
|
|
356
|
+
"offset": { "x": 0, "y": 18 },
|
|
357
|
+
"origin": { "x": 15, "y": 10 },
|
|
358
|
+
"z": 4,
|
|
359
|
+
"states": { "mouth": ["a", "mid", "open"] }
|
|
360
|
+
}
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
| フィールド | 規約 |
|
|
366
|
+
|---|---|
|
|
367
|
+
| `version` | 整数 `2` |
|
|
368
|
+
| `size`, `anchor` | v0 と同じ出力キャンバス px と正規化アンカー |
|
|
369
|
+
| `parts[].id` | セット内で一意な ASCII 識別子 |
|
|
370
|
+
| `image` | セット内の PNG への相対パス。パーツごとに異なる実寸を許す |
|
|
371
|
+
| `parent` | 親 id。ルートは `null`。複数ルートを許すが循環・欠落親は拒否する |
|
|
372
|
+
| `offset` | 親の `origin` から当該パーツの `origin` までの px。ルートではキャンバス左上基準 |
|
|
373
|
+
| `origin` | 当該 PNG 左上基準の回転・拡縮原点 px |
|
|
374
|
+
| `z` | 小さい値から描く。等値は `parts[]` 宣言順 |
|
|
375
|
+
| `states` | `"always"`、または `mouth` / `eyes` / `emotion` ごとの許可値配列 |
|
|
376
|
+
| `physics` | 省略可。下節の `wobble` / `follow` / `rotationalDrag` / `talkBounce` |
|
|
377
|
+
|
|
378
|
+
`states` に複数の駆動列があれば AND、同じ配列内の値は OR とする。口は
|
|
379
|
+
`closed/mid/open/a/i/u/e/o`、目は `open/closed`、emotion は v0.2 の語彙をそのまま使う。
|
|
380
|
+
たとえば `{mouth:["a","mid","open"],emotion:["happy"]}` は happy かつ該当口形の frame だけ
|
|
381
|
+
表示する。母音モードでは、セット全体の `states.mouth` に `closed/a/i/u/e/o` が存在することを
|
|
382
|
+
開始前に検査する。
|
|
383
|
+
|
|
384
|
+
変換は親先行で評価する。行列を列ベクトルへ左から適用するとき、パーツ `p` の pivot 行列は
|
|
385
|
+
`Pp = Pparent · T(offset + wobble) · R(rotationalDrag)`、画像行列は
|
|
386
|
+
`Ip = Pp · T(-origin)` である。ルートの `Pparent` はキャンバス中心を原点とする v1.1 motion 行列で、
|
|
387
|
+
`--no-motion` 時は恒等行列とする。これにより既存の呼吸・発話バウンス・微傾きは別の全画面後処理に
|
|
388
|
+
せず、ルートパーツへ一度だけ適用される。描画は全パーツを `z` 順に straight-alpha over 合成し、
|
|
389
|
+
サンプリングは premultiplied-alpha bilinear とする。
|
|
390
|
+
|
|
391
|
+
### v2.2 物理語彙と決定論
|
|
392
|
+
|
|
393
|
+
frame `f`、`t=f/fps`、固定ステップ `dt=1/fps` とする。壁時計、可変 delta、OS 乱数を使わない。
|
|
394
|
+
phase を省略した wobble だけは、正規化済み入力から得た motion seed と part id と軸名を SHA-256
|
|
395
|
+
へ入れ、その先頭 32 bit を `[0,2π)` へ写像する。
|
|
396
|
+
|
|
397
|
+
**wobble** は軸ごとの閉形式サイン波である。
|
|
398
|
+
|
|
399
|
+
```text
|
|
400
|
+
wobbleAxis(t) = amplitude · sin(2π · frequency · t + phase)
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`amplitude` は px、`frequency` は Hz、`phase` は rad。x/y は独立で、未指定軸は 0 px とする。
|
|
404
|
+
|
|
405
|
+
**follow** は親が示す現在 frame の target pivot を、パーツが保持する world 座標へ lerp する。
|
|
406
|
+
`drag >= 1` で、frame 0 は target へ初期化し、以後は次式とする。`drag=1` は遅れなしである。
|
|
407
|
+
|
|
408
|
+
```text
|
|
409
|
+
followed[f] = followed[f-1] + (target[f] - followed[f-1]) / drag
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**rotationalDrag** は target と followed の world x 差を角度へ変換し、角度自体を lerp する。
|
|
413
|
+
|
|
414
|
+
```text
|
|
415
|
+
targetDeg[f] = clamp((targetX[f] - followedX[f]) · strength, minDeg, maxDeg)
|
|
416
|
+
angle[f] = angle[f-1] + (targetDeg[f] - angle[f-1]) · lerp
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`strength` は degree/px、`minDeg/maxDeg` の既定は `-180/180`、`lerp` の既定は `0.25`。
|
|
420
|
+
|
|
421
|
+
**talkBounce** は closed から発話へ変わった frame で上向き初速を入れる固定 dt の放物運動である。
|
|
422
|
+
|
|
423
|
+
```text
|
|
424
|
+
onset: velocityY = -velocity
|
|
425
|
+
velocityY[f] = velocityY[f-1] + gravity · dt
|
|
426
|
+
bounceY[f] = bounceY[f-1] + velocityY[f] · dt
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`bounceY` が 0 を越えたら 0 にクランプして停止し、反発はしない。値の単位は `velocity=px/s`、
|
|
430
|
+
`gravity=px/s²`。v1.1 の標準発話バウンスはルート motion として既に存在するため、通常の v2 セットは
|
|
431
|
+
ルートへ `talkBounce` を重ねない。この語彙は Plus import が明示的に値を持つ場合、または個別パーツを
|
|
432
|
+
発話 onset で跳ねさせる場合の受け口である。
|
|
433
|
+
|
|
434
|
+
同じ parts.json、PNG、駆動列、fps、profile、CLI 値、ffmpeg 実装なら、物理列、RGBA frame、MOV、
|
|
435
|
+
stdout は byte 単位で一致する。stdout の `stats.follow_lag_frames` は follow 対象ごとに、target と
|
|
436
|
+
followed の変動が大きい軸を選んで相互相関が最大になる 0〜2 秒の非負 lag を実測した frame 数である。
|
|
437
|
+
|
|
438
|
+
### v2.3 PNGTuber Plus 語彙対応
|
|
439
|
+
|
|
440
|
+
PNGTuber Plus 1.4.5(Unlicense)の保存・実行コードを仕様の一次資料として読み、コードは流用せず
|
|
441
|
+
次の語彙を独自実装した。
|
|
442
|
+
|
|
443
|
+
| PNGTuber Plus `.save` / 挙動 | parts.json v2 | 変換 |
|
|
444
|
+
|---|---|---|
|
|
445
|
+
| `identification` | `id` | 文字列化しセット内一意にする |
|
|
446
|
+
| `parentId` | `parent` | identification 参照を id 参照へ変換。null は維持 |
|
|
447
|
+
| `pos`, `offset` | `offset`, `origin` | Plus の pivot を解決して親原点相対 px へ正規化 |
|
|
448
|
+
| `zindex` | `z` | 数値を維持。等値は import 宣言順 |
|
|
449
|
+
| `xAmp/xFrq`, `yAmp/yFrq` | `physics.wobble` | `amplitude=Amp`、Plus の rad/frame を `frequency=Frq·fps/(2π)` へ換算 |
|
|
450
|
+
| `drag` | `physics.follow.drag` | Plus の `lerp(...,1/dragSpeed)` と同じ係数。0/無効は `drag=1` |
|
|
451
|
+
| `rotDrag`, `rLimitMin/Max` | `physics.rotationalDrag` | world 追従差 → degree/px とクランプへ正規化、角度 lerp は `0.25` |
|
|
452
|
+
| global `bounce`, `gravity` | `physics.talkBounce` | import 厳密再現時は Plus 固定 `dt=0.0166` の sample を出力 fps へ再サンプルする |
|
|
453
|
+
| `showTalk`, `showBlink` | `states.mouth/eyes` | Plus の speaking/blink 表を AKARI の明示状態集合へ展開 |
|
|
454
|
+
| `stretchAmount` | 予約 | v2 は受理しない。将来の part scale drag として追加予定 |
|
|
455
|
+
| `clipped`, costume, flipbook, toggle | 予約 | import 時に黙って捨てず unsupported として報告する |
|
|
456
|
+
|
|
457
|
+
### v2.4 import 予約
|
|
458
|
+
|
|
459
|
+
`.save` importer 自体は本版に含めない。将来 importer は埋め込み `imageData` を優先可能な PNG として
|
|
460
|
+
抽出し、`path` は入力ファイル基準かつ境界内に解決できる場合だけ使う。整数辞書順を宣言順として
|
|
461
|
+
parent tree と z を移し、上表の物理値と showTalk/showBlink を変換する。未対応フィールドは
|
|
462
|
+
`unsupported[]`、変換で近似した値は `approximated[]` に必ず列挙し、モデル画像の利用権はユーザーが
|
|
463
|
+
持ち込んだ範囲に限定する。export、配布モデルの収集・同梱は別契約とする。
|
|
464
|
+
|
|
465
|
+
### v2.5 検証追加
|
|
466
|
+
|
|
467
|
+
1. parent が宣言上は子より後でも親先行で解決し、循環・欠落親を拒否する。
|
|
468
|
+
2. `z` 昇順・同値宣言順を固定し、親子変換後の基準点を数値で照合する。
|
|
469
|
+
3. mouth/eyes/emotion の状態ごとに表示パーツが一意に切り替わる。
|
|
470
|
+
4. wobble の閉形式値と固定 dt の follow/rotational drag/talk bounce を同入力 2 回で一致させる。
|
|
471
|
+
5. 12 秒 say fixture で 3 髪房すべての `follow_lag_frames > 0` を測り、MOV SHA を 2 回一致させる。
|
|
472
|
+
6. 既存 sprite.json のテスト fixture と `--no-motion` 経路は従来の寸法・画素・stdout を維持する。
|
|
473
|
+
|
|
474
|
+
## v2.6 追記(2026-08-14): 口状態切替のクロスフェード遷移
|
|
475
|
+
|
|
476
|
+
`--mouth-transition <frames>` は、口状態が変わる境界からクロスフェードする frame 数 `N` を指定する。
|
|
477
|
+
既定値は `2`。`0` は従来どおりの瞬間切替であり、遷移計算とブレンドを一切通らない。
|
|
478
|
+
|
|
479
|
+
| 値 | 口状態切替 |
|
|
480
|
+
|---:|---|
|
|
481
|
+
| `0` | 境界 frame から新状態を直接描画する |
|
|
482
|
+
| `N > 0` | 境界 frame から `N` frame を前状態と新状態のクロスフェードにする |
|
|
483
|
+
|
|
484
|
+
境界 frame からのオフセットを `p=0..N-1` とし、配列末尾を越える frame は生成しない。ブレンド係数は
|
|
485
|
+
次式で固定する。遷移中に次の口状態境界が現れた場合は、後の境界を優先する。
|
|
486
|
+
|
|
487
|
+
```text
|
|
488
|
+
t = (p + 1) / (N + 1)
|
|
489
|
+
output = previous · (1 - t) + current · t
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
これは「前状態 α=`(1-t)` + 新状態 α=`t`」の単純合成であり、RGBA の各チャンネルを直接 lerp して
|
|
493
|
+
最寄りの整数へ丸める。アルファ加重の over 合成ではない。
|
|
494
|
+
|
|
495
|
+
この遷移は sprite.json v1 経路と parts.json v2 経路の両方へ適用する。v1 は同じ目状態で合成した
|
|
496
|
+
前後の口 variant をブレンドしてから v1.1 のアフィン変換を適用する。v2 は現在 frame の物理・行列・
|
|
497
|
+
`z`・宣言順をそのまま使い、口状態に依存する visibility だけを前状態と新状態へ振り替えた 2 frame を
|
|
498
|
+
描画してブレンドする。したがって口以外のパーツ、物理、モーションは遷移の影響を受けない。
|
|
499
|
+
|
|
500
|
+
係数、境界走査、チャンネル補間は乱数・壁時計を使わない。同じ入力、同じ CLI 値、同じ ffmpeg 実装なら
|
|
501
|
+
stdout と MOV は byte 単位で決定論的に一致する。
|
|
502
|
+
|
|
503
|
+
既定が非ゼロ (`2`) になったため、v2.6 の既定出力は口状態境界の画素が従来から変わる後方非互換点が
|
|
504
|
+
ある。従来と同じ瞬間切替、画素、MOV を必要とする場合は `--mouth-transition 0` を指定する。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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. Run them from a full AKARI Video app installation (normally ~/.akari/app) or a monorepo checkout.]",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -86,7 +86,12 @@ export async function verifyPaidZipContents(extractedRoot, id) {
|
|
|
86
86
|
if (!match) {
|
|
87
87
|
throw new AssetResolverError(`checksums.txt の行を解釈できません: ${id}: ${trimmed}`, 'integrity');
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
const relPath = match[2];
|
|
90
|
+
// zip-slip 防御: checksums.txt 経由で packageDir の外を参照させない(絶対パス・`..` 拒否)
|
|
91
|
+
if (path.isAbsolute(relPath) || relPath.split(/[\\/]/).includes('..')) {
|
|
92
|
+
throw new AssetResolverError(`checksums.txt に不正なパスがあります: ${id}: ${relPath}`, 'integrity');
|
|
93
|
+
}
|
|
94
|
+
expected.push({ sha256: match[1], relPath });
|
|
90
95
|
}
|
|
91
96
|
if (expected.length === 0) {
|
|
92
97
|
throw new AssetResolverError(`checksums.txt が空です: ${id}`, 'integrity');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// resolve(id): 「使った素材だけをオンデマンドで取得する」の核。
|
|
2
2
|
//
|
|
3
3
|
// キャッシュヒット → 即パスを返す。未取得 → 全ファイルを一時ディレクトリへ実体化 →
|
|
4
|
-
// sha256 検証 →
|
|
4
|
+
// sha256 検証 → validate-asset で契約検証(無料経路は meta.json を持つ素材のみ・有料経路は必須)→
|
|
5
5
|
// 全部通ってから ~/.akari/assets/<category>/<id>/ へ原子的に move する。
|
|
6
6
|
// 失敗は fail-closed(一時ディレクトリを破棄し、登録先には部分状態を残さない)。
|
|
7
7
|
// 有料未購入(locked)は resolve を拒否する。
|
|
@@ -198,16 +198,38 @@ async function resolvePaidZip(item, { env, fetchImpl, project, home, destDir })
|
|
|
198
198
|
extractZip(zipPath, extractedRoot);
|
|
199
199
|
const { packageDir, payloadFiles } = await verifyPaidZipContents(extractedRoot, item.id);
|
|
200
200
|
|
|
201
|
+
// ストア入稿の実形は pack 形状(zip ルートに PACK.json / docs/ を持ち、素材本体は
|
|
202
|
+
// assets/<category>/<id>/ 配下)。契約 v0 のフラット形状(zip 直下に meta.json / 本体)も
|
|
203
|
+
// 引き続き受け付ける。どちらの形でも、ライブラリへ入るのは素材ディレクトリの中身だけ
|
|
204
|
+
// (PACK.json / docs はライブラリに混ぜない — 全量が必要なら `akari store download` が zip を渡す)。
|
|
205
|
+
const nestedPrefix = `assets/${item.category}/${item.id}/`;
|
|
206
|
+
const nestedFiles = payloadFiles
|
|
207
|
+
.filter((relPath) => relPath.startsWith(nestedPrefix))
|
|
208
|
+
.map((relPath) => relPath.slice(nestedPrefix.length));
|
|
209
|
+
const payloadRoot = nestedFiles.length > 0
|
|
210
|
+
? path.join(packageDir, 'assets', item.category, item.id)
|
|
211
|
+
: packageDir;
|
|
212
|
+
const assetFiles = nestedFiles.length > 0 ? nestedFiles : payloadFiles;
|
|
213
|
+
|
|
214
|
+
// 有料素材は meta.json 必須(契約検証を必ず通す)。形の取り違えを「meta.json の無い素材」と
|
|
215
|
+
// 誤認して検証スキップのまま通した前歴(#25)があるため fail-closed に倒す
|
|
216
|
+
if (!assetFiles.includes('meta.json')) {
|
|
217
|
+
throw new AssetResolverError(
|
|
218
|
+
`有料素材の zip に meta.json がありません(期待: assets/<category>/<id>/meta.json または zip 直下): ${item.id}`,
|
|
219
|
+
'integrity',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
await mkdir(tempAssetDir, { recursive: true });
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
await cp(path.join(
|
|
224
|
+
for (const relPath of assetFiles) {
|
|
225
|
+
const destFile = path.join(tempAssetDir, relPath);
|
|
226
|
+
await mkdir(path.dirname(destFile), { recursive: true });
|
|
227
|
+
await cp(path.join(payloadRoot, relPath), destFile, {
|
|
206
228
|
mode: constants.COPYFILE_FICLONE,
|
|
207
229
|
});
|
|
208
230
|
}
|
|
209
231
|
|
|
210
|
-
|
|
232
|
+
{
|
|
211
233
|
const result = spawnSync(process.execPath, [VALIDATE_ASSET_SCRIPT, tempAssetDir], { encoding: 'utf8' });
|
|
212
234
|
if (result.status !== 0) {
|
|
213
235
|
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
@@ -48,8 +48,13 @@ function paidMetaBuffer(id, category) {
|
|
|
48
48
|
* 契約 §6 の zip 構成(`<id>-v<version>/` 直下に README.md / LICENSE.md / 本体 / checksums.txt)を
|
|
49
49
|
* システムの `zip` コマンドで実際に作る。`corrupt: '<name>'` を指定すると、その名前の
|
|
50
50
|
* checksums.txt エントリだけ sha256 を意図的にずらす(checksums 不一致テスト用)。
|
|
51
|
+
*
|
|
52
|
+
* `layout: 'pack'` はストア入稿の実形(#25 で発覚)を作る: zip ルートに PACK.json / docs/ を持ち、
|
|
53
|
+
* 素材本体は assets/<category>/<id>/ 配下。`withoutMeta: true` は meta.json を落とした zip を作る
|
|
54
|
+
* (有料経路の meta.json 必須化テスト用)。`extraChecksumLine` は checksums.txt に任意の 1 行を
|
|
55
|
+
* 追記する(zip-slip 防御テスト用)。
|
|
51
56
|
*/
|
|
52
|
-
function buildPaidZip(id, category, { corrupt } = {}) {
|
|
57
|
+
function buildPaidZip(id, category, { corrupt, layout = 'flat', withoutMeta = false, extraChecksumLine } = {}) {
|
|
53
58
|
const stage = mkdtempSync(path.join(tmpdir(), 'paid-zip-fixture-'));
|
|
54
59
|
const rootName = `${id}-v1`;
|
|
55
60
|
const rootDir = path.join(stage, rootName);
|
|
@@ -64,19 +69,31 @@ function buildPaidZip(id, category, { corrupt } = {}) {
|
|
|
64
69
|
'model.glb': Buffer.from('glTF-fixture-not-a-real-binary'),
|
|
65
70
|
'preview.png': MINI_PNG,
|
|
66
71
|
};
|
|
72
|
+
if (withoutMeta) delete payload['meta.json'];
|
|
73
|
+
|
|
74
|
+
const payloadPrefix = layout === 'pack' ? `assets/${category}/${id}/` : '';
|
|
67
75
|
const allFiles = {
|
|
68
76
|
'README.md': Buffer.from('# fixture\n'),
|
|
69
77
|
'LICENSE.md': Buffer.from('fixture license\n'),
|
|
70
|
-
...payload,
|
|
71
78
|
};
|
|
79
|
+
if (layout === 'pack') {
|
|
80
|
+
allFiles['PACK.json'] = Buffer.from(`${JSON.stringify({ id, version: 1 })}\n`);
|
|
81
|
+
allFiles['docs/GUIDE.md'] = Buffer.from('# pack guide fixture\n');
|
|
82
|
+
}
|
|
83
|
+
for (const [name, buffer] of Object.entries(payload)) {
|
|
84
|
+
allFiles[`${payloadPrefix}${name}`] = buffer;
|
|
85
|
+
}
|
|
72
86
|
for (const [name, buffer] of Object.entries(allFiles)) {
|
|
73
|
-
|
|
87
|
+
const filePath = path.join(rootDir, name);
|
|
88
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
89
|
+
writeFileSync(filePath, buffer);
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
const checksumLines = Object.keys(allFiles).sort().map((name) => {
|
|
77
93
|
const digest = name === corrupt ? '0'.repeat(64) : sha256(allFiles[name]);
|
|
78
94
|
return `${digest} ${name}`;
|
|
79
95
|
});
|
|
96
|
+
if (extraChecksumLine) checksumLines.push(extraChecksumLine);
|
|
80
97
|
writeFileSync(path.join(rootDir, 'checksums.txt'), `${checksumLines.join('\n')}\n`);
|
|
81
98
|
|
|
82
99
|
const zipPath = path.join(stage, `${rootName}.zip`);
|
|
@@ -220,3 +237,76 @@ test('resolvePaidZip: ダウンロード失敗(トークン失効等)も fai
|
|
|
220
237
|
|
|
221
238
|
assert.equal(existsSync(path.join(home, 'assets', 'scene3d', 'mini-paid-zip')), false);
|
|
222
239
|
});
|
|
240
|
+
|
|
241
|
+
test('resolvePaidZip: pack 形状(assets/<category>/<id>/ 配下)は素材本体へ降りて登録される(#25)', async () => {
|
|
242
|
+
const { env, home, catalog, catalogPath } = setupFixtureEnv();
|
|
243
|
+
writeCredentials(home);
|
|
244
|
+
addPaidCatalogItem(catalog, catalogPath, 'mini-paid-pack', 'scene3d', 2980);
|
|
245
|
+
const zipPath = buildPaidZip('mini-paid-pack', 'scene3d', { layout: 'pack' });
|
|
246
|
+
|
|
247
|
+
const result = await resolveAsset('mini-paid-pack', {
|
|
248
|
+
env,
|
|
249
|
+
fetchImpl: fetchImplFor('mini-paid-pack', { entitled: true, zipPath }),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// meta.json がライブラリ直下(<category>/<id>/meta.json)に来る — #25 の壊れ形の否定
|
|
253
|
+
assert.equal(result.dir, path.join(home, 'assets', 'scene3d', 'mini-paid-pack'));
|
|
254
|
+
assert.ok(existsSync(path.join(result.dir, 'meta.json')));
|
|
255
|
+
assert.ok(existsSync(path.join(result.dir, 'fragment.html')));
|
|
256
|
+
assert.ok(existsSync(path.join(result.dir, 'model.glb')));
|
|
257
|
+
// pack の梱包物(PACK.json / docs/)と入れ子の assets/ はライブラリに混ぜない
|
|
258
|
+
assert.equal(existsSync(path.join(result.dir, 'PACK.json')), false);
|
|
259
|
+
assert.equal(existsSync(path.join(result.dir, 'docs')), false);
|
|
260
|
+
assert.equal(existsSync(path.join(result.dir, 'assets')), false);
|
|
261
|
+
|
|
262
|
+
const meta = JSON.parse(readFileSync(path.join(result.dir, 'meta.json'), 'utf8'));
|
|
263
|
+
assert.equal(meta.id, 'mini-paid-pack');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('resolvePaidZip: meta.json の無い有料 zip は fail-closed(無警告スキップを許さない — #25)', async () => {
|
|
267
|
+
const { env, home, catalog, catalogPath } = setupFixtureEnv();
|
|
268
|
+
writeCredentials(home);
|
|
269
|
+
addPaidCatalogItem(catalog, catalogPath, 'mini-paid-nometa', 'scene3d', 2980);
|
|
270
|
+
const zipPath = buildPaidZip('mini-paid-nometa', 'scene3d', { withoutMeta: true });
|
|
271
|
+
|
|
272
|
+
await assert.rejects(
|
|
273
|
+
() => resolveAsset('mini-paid-nometa', {
|
|
274
|
+
env,
|
|
275
|
+
fetchImpl: fetchImplFor('mini-paid-nometa', { entitled: true, zipPath }),
|
|
276
|
+
}),
|
|
277
|
+
(error) => {
|
|
278
|
+
assert.ok(error instanceof AssetResolverError);
|
|
279
|
+
assert.equal(error.code, 'integrity');
|
|
280
|
+
assert.match(error.message, /meta\.json/);
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
assert.equal(existsSync(path.join(home, 'assets', 'scene3d', 'mini-paid-nometa')), false);
|
|
286
|
+
const stray = readdirSync(home).filter((name) => name.startsWith('.tmp-resolve-'));
|
|
287
|
+
assert.deepEqual(stray, [], '一時ディレクトリが破棄されていること');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('resolvePaidZip: checksums.txt の `..` パスは zip-slip として拒否される', async () => {
|
|
291
|
+
const { env, home, catalog, catalogPath } = setupFixtureEnv();
|
|
292
|
+
writeCredentials(home);
|
|
293
|
+
addPaidCatalogItem(catalog, catalogPath, 'mini-paid-slip', 'scene3d', 2980);
|
|
294
|
+
const zipPath = buildPaidZip('mini-paid-slip', 'scene3d', {
|
|
295
|
+
extraChecksumLine: `${'0'.repeat(64)} ../outside.txt`,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await assert.rejects(
|
|
299
|
+
() => resolveAsset('mini-paid-slip', {
|
|
300
|
+
env,
|
|
301
|
+
fetchImpl: fetchImplFor('mini-paid-slip', { entitled: true, zipPath }),
|
|
302
|
+
}),
|
|
303
|
+
(error) => {
|
|
304
|
+
assert.ok(error instanceof AssetResolverError);
|
|
305
|
+
assert.equal(error.code, 'integrity');
|
|
306
|
+
assert.match(error.message, /不正なパス/);
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
assert.equal(existsSync(path.join(home, 'assets', 'scene3d', 'mini-paid-slip')), false);
|
|
312
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Local port of packages/render-cut/src/cut-timeline.mjs's cutSpeed/segmentDuration and
|
|
2
|
+
// packages/render-cut/src/cut-freeze.mjs's freezeDurationSeconds. edit-lint is a lower-level
|
|
3
|
+
// package render-cut depends on, so it cannot import back from render-cut. Keep this copy in
|
|
4
|
+
// sync whenever the render-cut implementations change.
|
|
5
|
+
export function cutSpeed(cut) {
|
|
6
|
+
const value = cut?.speed;
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function freezeDurationSeconds(freeze) {
|
|
11
|
+
const value = freeze?.duration_sec;
|
|
12
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function segmentDuration(cut) {
|
|
16
|
+
return (cut.out - cut.in) / cutSpeed(cut) + freezeDurationSeconds(cut?.freeze);
|
|
17
|
+
}
|
|
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
|
|
|
14
14
|
|
|
15
15
|
import { renderLintReport } from "./report.mjs";
|
|
16
16
|
import { deriveTracks } from "./derive-tracks.mjs";
|
|
17
|
+
import { segmentDuration } from "./cut-timeline.mjs";
|
|
17
18
|
import { musicGrid } from "../../audio-library-setup/shared/beat-grid.mjs";
|
|
18
19
|
import { resolveFfmpeg, resolveFfprobe } from "../../media-bin/src/index.mjs";
|
|
19
20
|
|
|
@@ -222,6 +223,7 @@ export async function lintProject(input, options = {}) {
|
|
|
222
223
|
edit.version,
|
|
223
224
|
structure.sourceIds,
|
|
224
225
|
);
|
|
226
|
+
validateFrameGridAlignment(edit.cuts, edit?.output?.fps, findings);
|
|
225
227
|
validateCutTrackFields(edit.cuts, findings);
|
|
226
228
|
validateCutTransformFields(edit.cuts, findings);
|
|
227
229
|
validateStillImageCuts(edit, findings);
|
|
@@ -599,8 +601,8 @@ function validateTransitionOut(value, findings, path) {
|
|
|
599
601
|
addFinding(findings, { severity: "error", check: "cuts.transition-out.structure", message: "transition_out must be an object", path });
|
|
600
602
|
return;
|
|
601
603
|
}
|
|
602
|
-
if (!["dissolve", "fade-black", "fade-white"].includes(value.type)) {
|
|
603
|
-
addFinding(findings, { severity: "error", check: "cuts.transition-out.type", message: "type must be dissolve/fade-black/fade-white", path });
|
|
604
|
+
if (!["dissolve", "fade-black", "fade-white", "reveal-down", "reveal-up"].includes(value.type)) {
|
|
605
|
+
addFinding(findings, { severity: "error", check: "cuts.transition-out.type", message: "type must be dissolve/fade-black/fade-white/reveal-down/reveal-up", path });
|
|
604
606
|
}
|
|
605
607
|
if (!isPositiveNumber(value.duration)) {
|
|
606
608
|
addFinding(findings, { severity: "error", check: "cuts.transition-out.duration", message: "duration must be a positive number", path });
|
|
@@ -1240,7 +1242,7 @@ function validateCuts(cuts, sourceDuration, findings, paths, version, sourceIds)
|
|
|
1240
1242
|
});
|
|
1241
1243
|
valid = false;
|
|
1242
1244
|
} else {
|
|
1243
|
-
timeline += cut
|
|
1245
|
+
timeline += segmentDuration(cut);
|
|
1244
1246
|
}
|
|
1245
1247
|
if (version === 1) {
|
|
1246
1248
|
if (!isNonEmptyString(cut.src)) {
|
|
@@ -1317,6 +1319,37 @@ function validateCuts(cuts, sourceDuration, findings, paths, version, sourceIds)
|
|
|
1317
1319
|
return valid ? timeline : null;
|
|
1318
1320
|
}
|
|
1319
1321
|
|
|
1322
|
+
function validateFrameGridAlignment(cuts, fps, findings) {
|
|
1323
|
+
if (!Array.isArray(cuts) || !isPositiveNumber(fps)) return;
|
|
1324
|
+
const frameDuration = 1 / fps;
|
|
1325
|
+
let position = 0;
|
|
1326
|
+
|
|
1327
|
+
for (const [index, cut] of cuts.entries()) {
|
|
1328
|
+
if (
|
|
1329
|
+
!isRecord(cut) ||
|
|
1330
|
+
!isFiniteNumber(cut.in) ||
|
|
1331
|
+
!isFiniteNumber(cut.out) ||
|
|
1332
|
+
cut.out <= cut.in
|
|
1333
|
+
) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
const duration = segmentDuration(cut);
|
|
1337
|
+
position += duration;
|
|
1338
|
+
const frames = position / frameDuration;
|
|
1339
|
+
const nearest = Math.round(frames);
|
|
1340
|
+
const errorFrames = Math.abs(frames - nearest);
|
|
1341
|
+
if (errorFrames <= 0.05) continue;
|
|
1342
|
+
|
|
1343
|
+
const cutFrames = duration / frameDuration;
|
|
1344
|
+
addFinding(findings, {
|
|
1345
|
+
severity: "warning",
|
|
1346
|
+
check: "cuts.frame-grid",
|
|
1347
|
+
message: `cut ${index} duration ${duration.toFixed(4)}s is not frame-aligned at ${fps}fps (${cutFrames.toFixed(1)} frames) -- boundary-synced overlays/transitions may shift by one frame`,
|
|
1348
|
+
path: `edit.json#cuts[${index}]`,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1320
1353
|
function validateDurationMaximum(outputs, timeline, findings) {
|
|
1321
1354
|
if (outputs === undefined) return;
|
|
1322
1355
|
if (!Array.isArray(outputs)) {
|
|
@@ -1052,8 +1052,8 @@ function validateTransitionOut(value, label) {
|
|
|
1052
1052
|
fail(`${label} は object である必要があります`);
|
|
1053
1053
|
return;
|
|
1054
1054
|
}
|
|
1055
|
-
if (!["dissolve", "fade-black", "fade-white"].includes(value.type)) {
|
|
1056
|
-
fail(`${label}.type は dissolve/fade-black/fade-white のいずれかである必要があります`);
|
|
1055
|
+
if (!["dissolve", "fade-black", "fade-white", "reveal-down", "reveal-up"].includes(value.type)) {
|
|
1056
|
+
fail(`${label}.type は dissolve/fade-black/fade-white/reveal-down/reveal-up のいずれかである必要があります`);
|
|
1057
1057
|
}
|
|
1058
1058
|
if (!isFiniteNumber(value.duration) || value.duration <= 0) {
|
|
1059
1059
|
fail(`${label}.duration は 0 より大きい有限数である必要があります`);
|
|
@@ -223,10 +223,10 @@
|
|
|
223
223
|
"additionalProperties": true,
|
|
224
224
|
"required": ["type", "duration"],
|
|
225
225
|
"properties": {
|
|
226
|
-
"type": { "enum": ["dissolve", "fade-black", "fade-white"] },
|
|
226
|
+
"type": { "enum": ["dissolve", "fade-black", "fade-white", "reveal-down", "reveal-up"] },
|
|
227
227
|
"duration": { "$ref": "#/$defs/positiveNumber" }
|
|
228
228
|
},
|
|
229
|
-
"$comment": "docs/contract-2026-07-22-render-basics.md #3。v0 は指定されたカット境界のみ xfade 経路(残裁定3)。ffmpeg xfade の transition 名へ直接対応: dissolve/fadeblack/fadewhite。"
|
|
229
|
+
"$comment": "docs/contract-2026-07-22-render-basics.md #3。v0 は指定されたカット境界のみ xfade 経路(残裁定3)。ffmpeg xfade の transition 名へ直接対応: dissolve/fadeblack/fadewhite/revealdown/revealup。reveal-down / reveal-up は前カットが丸ごとその方向へ動いて画面外へ抜け、空いた側から次カットが現れる(2026-08-14 追加)。"
|
|
230
230
|
},
|
|
231
231
|
"cutTransform": {
|
|
232
232
|
"type": "object",
|
|
@@ -197,12 +197,12 @@ test("source.chroma_key.color is required", () => {
|
|
|
197
197
|
assert.match(executed.stderr, /source\.chroma_key\.color は空でない文字列である必要があります/);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
test("cuts[].transition_out.type must be dissolve/fade-black/fade-white", () => {
|
|
200
|
+
test("cuts[].transition_out.type must be dissolve/fade-black/fade-white/reveal-down/reveal-up", () => {
|
|
201
201
|
const executed = run("edit-transition-invalid");
|
|
202
202
|
assert.equal(executed.status, 1, executed.stdout);
|
|
203
203
|
assert.match(
|
|
204
204
|
executed.stderr,
|
|
205
|
-
/cuts\[0\]\.transition_out\.type は dissolve\/fade-black\/fade-white のいずれかである必要があります/,
|
|
205
|
+
/cuts\[0\]\.transition_out\.type は dissolve\/fade-black\/fade-white\/reveal-down\/reveal-up のいずれかである必要があります/,
|
|
206
206
|
);
|
|
207
207
|
});
|
|
208
208
|
|
|
@@ -45,6 +45,31 @@ BGM と SFX は [音声契約](../../docs/contract-2026-07-14-edit-json-v1-audio
|
|
|
45
45
|
- スライドショーのように静止画を順に見せる構成も、画像 1 枚につき `layers[]` 1 項目(`track` 付き)で表す。**静止画群を ffmpeg で 1 本の動画へ連結してから source にしない** — 個々の画像の差し替え・タイミング調整の編集性が失われ、`edit.json` の SSOT が壊れる。これは上記「承認を得ずに黙って concat しない」の具体例である。
|
|
46
46
|
- cuts のソース(v1 `sources[].path` / v0 `source.path`)に静止画を書く経路は**現状未対応**(レンダーが時間軸を持つ動画を前提とする)。メイン時間軸そのものを静止画で構成する必要があり `layers[]` で表せない場合は、連結動画で回避せず停止して報告する。
|
|
47
47
|
|
|
48
|
+
### レイヤー素材の時間基準(2026-08-14・実害から追記)
|
|
49
|
+
|
|
50
|
+
人物切り抜き・B-roll など `layers[]` に重ねる素材で、**十数フレーム単位のズレを作り込みやすい**
|
|
51
|
+
3 点。正本は `docs/contract-2026-08-02-preview-parity.md` §2.4 と
|
|
52
|
+
`docs/contract-2026-07-22-render-basics.md` §2-3。
|
|
53
|
+
|
|
54
|
+
- **`layers[]` に in トリムは無い。素材の先頭が常に `t` に対応する**(`cuts[].in/out` に相当する
|
|
55
|
+
ものが無い)。したがって素材が既にカット単位で切り出されているなら、**そのまま
|
|
56
|
+
`t = 使い始めたい時刻` / `duration = 使いたい長さ` を書くだけ**でよい。
|
|
57
|
+
「パネルが先に出ているから素材にもプリロールがあるはず」といった**推測でのトリムをしない**。
|
|
58
|
+
推測でトリムすると素材の頭が前へずれ、さらに `duration` を詰めた分だけ末尾で素材が尽きて
|
|
59
|
+
「重ねた素材が消えて下地だけになる」瞬間が出る。
|
|
60
|
+
- **素材の途中から使いたい場合は素材そのものを切り出すしかない**。切り出したら
|
|
61
|
+
**由来(元素材 / in / out / speed / fps)を素材の隣に必ず残す**。残さないと次に触る人が
|
|
62
|
+
「この素材の頭は何の時刻か」を推測することになり、同じ事故を繰り返す。
|
|
63
|
+
- **`output.look` の LUT は `cuts[]` の本編にしか掛からない**。重ねた素材は素の色のまま合成される。
|
|
64
|
+
色を合わせるには `layers[].filter` に**同じ `id` / `intensity` を明示的に宣言**する
|
|
65
|
+
(`{"type":"lut","id":"cinematic","intensity":0.5}`)。
|
|
66
|
+
- 時間対応を後から**実測**するときは、`blend=difference` の絶対差を使わない。上記の色差が
|
|
67
|
+
支配して指標が平坦になり、誤った結論(適当な lag が「最小」に見える)に落ちる。
|
|
68
|
+
**フレーム間差分エネルギーの時系列**(`format=gray,tblend=all_mode=difference` →
|
|
69
|
+
`signalstats` の YAVG)を素材側とカット側で取り、正規化して**相互相関**させると、
|
|
70
|
+
色に不変で lag を特定できる。カットに `speed` が掛かっている場合は、素材が
|
|
71
|
+
「速度適用済みで焼かれている」のか「素材レートのまま」なのかもこの相関で判別できる。
|
|
72
|
+
|
|
48
73
|
## 2. edit.json を作る
|
|
49
74
|
|
|
50
75
|
承認値を次の形へ入れる。例の数値を既定値として流用しない。
|
|
@@ -188,6 +213,12 @@ node packages/render-cut/bin/akari-apply-textstyle.mjs <project-dir> <preset-id>
|
|
|
188
213
|
- 按分 fallback を「区間の拡張」に使い、末尾語の推定 end を越えて次 caption まで字幕を伸ばす(§4)。
|
|
189
214
|
- **切り出し・先出しクリップを見せ場スコア位置の機械窓(segment 量子化時刻)のまま切り出し、意図した発話を窓の外へ落とす/全文 1.0s 未満の読めない字幕を出す**(発話へスナップし呼吸 0.5s を残す。§2)。
|
|
190
215
|
- `cuts` と overlay の時刻をどちらも source 秒で書く。
|
|
216
|
+
- **`layers[]` の素材の頭を「プリロールがあるはず」と推測してトリムする**(`layers[]` に in トリムは
|
|
217
|
+
無く、素材の先頭が常に `t`。切り出し済み素材ならそのまま `t = 使い始めたい時刻` に置く。§1 レイヤー素材の時間基準)。
|
|
218
|
+
- **`output.look` の LUT がレイヤーにも掛かると思い込む**(掛かるのは `cuts[]` の本編だけ。
|
|
219
|
+
重ねた素材は `layers[].filter` に同じ `id` / `intensity` を書いて揃える。§1 レイヤー素材の時間基準)。
|
|
220
|
+
- 重ねた素材とカットの時間ズレを `blend=difference` の絶対差で測る(色が違うと指標が平坦になり
|
|
221
|
+
誤った結論に落ちる。フレーム間差分の相互相関で測る。§1 レイヤー素材の時間基準)。
|
|
191
222
|
- 実行承認前に中間マスターや overlay を作る。
|
|
192
223
|
- authoring skill がないことを理由に規約を省略する。
|
|
193
224
|
- edit.json の sample 値を、承認されていない出力仕様として使う。
|
|
@@ -98,6 +98,8 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
|
|
|
98
98
|
確認し、白飛びを抑えるなら `exposure`、映り込み自体の強弱を変えるなら `intensity` を小幅に調整する。
|
|
99
99
|
IBL 自体(既定値での環境マップ・トーンマッピング)は preview / export とも常に有効 — `environment`
|
|
100
100
|
キーは既定値からの微調整用の任意 knob。
|
|
101
|
+
- **金属マテリアルは environment の映り込みでしか色が出ない。** 暗い場合は
|
|
102
|
+
`environment: { "intensity": ..., "exposure": ... }` を明示する。既定 intensity 0.5 + ACES では宣言色より大きく沈む。
|
|
101
103
|
- `environment.map` は**正距円筒(equirectangular)画像の相対パス**。宣言すると既定の手続き生成
|
|
102
104
|
`RoomEnvironment` を差し替える。金属は環境の映り込みでしか見えないので、光源に「形」が要る
|
|
103
105
|
ヒーローカットではこれが効く(既定の部屋は強度スカラーしか触れず、側面レールが黒く沈む)。
|
|
@@ -116,7 +118,7 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
|
|
|
116
118
|
`"*"` も指定できる。省略時は静的シーンを描画する。Blender の glTF 書き出しはオブジェクトごとに
|
|
117
119
|
clip を分けるので、1 個のモデルに複数の動きがあるときは `"*"` で束ねる(1 本しか再生しないと
|
|
118
120
|
片方しか動かない)。存在しない clip 名を書いた場合はエラーになる。
|
|
119
|
-
- `materialOverrides` は `{ "<material 名>": { "texture": "<画像または動画の相対パス>" } }` の形で、名前が一致するマテリアルの `emissiveMap`
|
|
121
|
+
- `materialOverrides` は `{ "<material 名>": { "texture": "<画像または動画の相対パス>" } }` の形で、名前が一致するマテリアルの `emissiveMap` を差し替える。**差し替え先が `emissiveMap` である以上、貼り先の材質が発光しない(glTF の `emissiveFactor` が未設定 = 黒)と「0 × テクスチャ」で何も出ない**(2026-08-14 実害。詳細は後述「発光しない材質には貼れない」)。edit.json のあるディレクトリからの相対 PNG / JPEG / WebP 等、または MP4 / MOV / WebM を指定し、URL や CDN を書かない。該当するマテリアル名がモデル内にない場合は警告して無視される。
|
|
120
122
|
- **パスは「edit.json のあるディレクトリ」から。断片の場所からの相対ではない。** `model` も同様。
|
|
121
123
|
断片を `overlays/3d-phone/fragment.html` へ置いたなら `"overlays/3d-phone/model.glb"` と書く
|
|
122
124
|
(`"model.glb"` はプロジェクト直下を探して ENOENT になる)。
|
|
@@ -560,6 +562,50 @@ container を渡しても、断片の外側からは同じ値が同じ式で読
|
|
|
560
562
|
- 同梱クリップの尺・向きが用途に合わないことは珍しくない。合わないなら**作り直す方が速い**
|
|
561
563
|
(§glb を直接編集する)
|
|
562
564
|
|
|
565
|
+
### 発光しない材質には貼れない(materialOverrides の実測 2026-08-14)
|
|
566
|
+
|
|
567
|
+
`materialOverrides` の差し替え先は `emissiveMap` 固定である。three は `emissive`(glTF の
|
|
568
|
+
`emissiveFactor`)を掛けてから合成するので、**`emissiveFactor` が未設定=黒の材質へ貼っても
|
|
569
|
+
「0 × テクスチャ」で完全に消える**。しかもエラーにならないため、原因に辿り着きにくい。
|
|
570
|
+
|
|
571
|
+
実害: アプリアイコンの glb は前面(`IconFaceMaterial`)が非発光で、`materialOverrides` を
|
|
572
|
+
宣言しても**白い樹脂の面のまま**だった。同じ宣言でスマホの画面(`ScreenMaterial` =
|
|
573
|
+
`emissiveFactor: [1,1,1]`)は正しく映るので、「宣言は合っているのに出ない」状態になる。
|
|
574
|
+
|
|
575
|
+
- 判定: 貼る前に glb の材質を確認する。`emissiveFactor` が無ければ貼れない
|
|
576
|
+
```sh
|
|
577
|
+
python3 -c "import json,struct;d=open('model.glb','rb').read();n=struct.unpack('<I',d[12:16])[0];j=json.loads(d[20:20+n]);print([(m['name'],m.get('emissiveFactor')) for m in j['materials']])"
|
|
578
|
+
```
|
|
579
|
+
- 対処 1(推奨): モデル側の当該材質に `emissiveFactor: [1,1,1]` を与える。あわせて
|
|
580
|
+
`baseColorFactor` を暗く落とすと、貼った絵の発色を下地が濁さない
|
|
581
|
+
- 対処 2: 発光する材質(画面用など)を対象にする
|
|
582
|
+
- ランタイムは非発光の材質を対象にすると**警告を出す**(2026-08-14 追加)。ログを見ること
|
|
583
|
+
|
|
584
|
+
### 動きプリセットは「正面を向く時間」を実測してから使う(2026-08-14)
|
|
585
|
+
|
|
586
|
+
`animationClip` の回転系プリセットは、**表示区間の大半で裏面や側面を向いていることがある**。
|
|
587
|
+
1〜2 秒しか出さないオーバーレイでは、貼ったテクスチャが一度も映らないまま終わる。
|
|
588
|
+
|
|
589
|
+
実害: アプリアイコンの 3 本(`Turntable` / `PopInDrop` / `TumbleReveal`)はいずれも序盤
|
|
590
|
+
約 1.5 秒がアイコン面の裏側で、1.8 秒の表示では白い面しか映らなかった。
|
|
591
|
+
**`animationClip` を外した初期姿勢が正面**だったため、短い露出では回さないのが正解だった。
|
|
592
|
+
|
|
593
|
+
- 短時間の露出では、まず `animationClip` 無し(初期姿勢)で焼いて向きを確かめる
|
|
594
|
+
- 回したい場合は、クリップの位相を実測して「正面が映る区間」に表示窓を合わせる
|
|
595
|
+
|
|
596
|
+
### 実写に重ねる小物は `--akari-3d-zoom` を 0.3〜0.5 まで落とす(2026-08-14)
|
|
597
|
+
|
|
598
|
+
`--akari-3d-zoom` の既定 1.0 は**フレームいっぱいに 3D が映る**設定。実写の上へアイコンや
|
|
599
|
+
端末をワンポイントで置く用途では大きすぎ、画面外へはみ出す。実測の目安はアイコン 0.30〜0.34、
|
|
600
|
+
スマホ実機 0.46。位置は `--akari-3d-pan-y`(正 = 下)で送る。
|
|
601
|
+
|
|
602
|
+
### 3D の確認は「最小プロジェクトを焼く」(2026-08-14)
|
|
603
|
+
|
|
604
|
+
`harness/render-fragment.mjs` の単体レンダーでは 3D シーンが `status=error` になる
|
|
605
|
+
(`model` / `texts[].font` がプロジェクトルート相対で解決されるため、プロジェクト文脈が要る)。
|
|
606
|
+
**確認は最小の `edit.json` を作って `render-cut` で焼き、フレームを抜いて目視する**。
|
|
607
|
+
断片だけを見て「貼れたはず」と判断しない。
|
|
608
|
+
|
|
563
609
|
### モデルの「既定ポーズ = 中立」と決めつけない(実測 2026-08-04)
|
|
564
610
|
|
|
565
611
|
ノート PC の `lid_root` で、回転 0°(単位クォータニオン)が **「閉じ」ではなく「180° フラット
|
|
@@ -78,25 +78,12 @@ rAF は、外部タイムラインから既に決めた状態を再描画する
|
|
|
78
78
|
1. 単発のループ演出(点滅など)は必ず両端を明示する(`0%, 100% { opacity: 1; } 50% { opacity: 0; }`)。片方しか書かない省略はしない
|
|
79
79
|
2. 同一プロパティを複数段でつなぐ場合、後段の `0%` / `from` に**前段の着地値を明示的に複製**する(暗黙合成に任せない)
|
|
80
80
|
|
|
81
|
-
### 書き出しランタイムは `animation-fill-mode`
|
|
81
|
+
### 書き出しランタイムは authored の `animation-fill-mode` を尊重する(2026-08-16 実測)
|
|
82
82
|
|
|
83
|
-
書き出し時、render-cut
|
|
83
|
+
書き出し時、render-cut は CSS animation を paused WAAPI クローンへ変換するが、`fill` は authored の宣言(`none` / `forwards` / `backwards` / `both`)をそのまま引き継ぐ。プレビューと書き出しで fill-mode の意味は一致する。
|
|
84
84
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
- **プレビューと書き出しで挙動が割れる**: ブラウザプレビューは authored fill-mode を尊重するため、プレビューだけ見ると `forwards` で直ったように見える
|
|
88
|
-
- 直し方: **過渡 FX は `0%` キーフレーム自体を不可視(`opacity: 0`)にし、最初の数 %(〜50ms 相当)で立ち上げる**。delay 中は backwards fill が `0%` を適用するので必ず隠れる
|
|
89
|
-
|
|
90
|
-
```css
|
|
91
|
-
@keyframes burst-ray {
|
|
92
|
-
0% { opacity: 0; transform: rotate(var(--a)) translateX(90px) scaleX(0.3); }
|
|
93
|
-
8% { opacity: 1; }
|
|
94
|
-
70% { opacity: 1; }
|
|
95
|
-
100% { opacity: 0; transform: rotate(var(--a)) translateX(300px) scaleX(1); }
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- 覚え方: **入場して留まる要素は from を「隠れ状態」で書くので `both` 前提と両立する。発火して消える要素は from こそ隠す**。fill-mode の書き分けで解決しようとしない(書き出しでは選べない)
|
|
85
|
+
- 同一要素へ IN と遅延付き OUT を並べる場合は、`animation: intro .3s both, outro .3s 1s forwards` のように OUT へ backward fill を付けない。OUT の `0%` が delay 中の IN を上書きせず、IN → hold → OUT が順に効く
|
|
86
|
+
- 遅延前から `0%` を見せたい演出だけ `backwards` / `both` を宣言する。過渡 FX を発火前に隠す場合は `forwards` / `none` を選ぶか、意図して backward fill を使うなら `0%` 自体を不可視にする
|
|
100
87
|
|
|
101
88
|
### 入場して留まる要素の base を隠れ状態にすると書き出しで消える(2026-08-14 実測)
|
|
102
89
|
|