Skip to content

Commit 10f0a36

Browse files
🔒 spec(svg-composition-animation): close SPEC — all phases complete
SPEC: 20260413-0806_svg-composition-animation All tasks checked off. Moved to closed.
1 parent 06932b6 commit 10f0a36

17 files changed

Lines changed: 19536 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
# Design: SVG Composition Animation
2+
3+
## Architecture
4+
5+
```
6+
run_python(measure_slides=[N])
7+
8+
PPTX build → LibreOffice SVG export (existing _run_measure)
9+
10+
split_slide_components() ← NEW
11+
├─ <font>/<glyph> 除去
12+
├─ base64 PNG → base64 WebP 変換
13+
├─ コンポーネント分割 + メタデータ抽出
14+
└─ 背景抽出
15+
16+
構造化JSON → S3保存 → presigned URL
17+
18+
WebUI polling → JSON fetch → SVG組み立て → アニメーション
19+
```
20+
21+
## Engine: `sdpm.preview.compose`
22+
23+
~~New module: `skill/sdpm/preview/compose.py`~~
24+
25+
**修正**: composeはMCP Remote専用ロジック(消費者はWebUIのみ、CLI/MCP Localでは不使用)。
26+
principlesの判断フロー「Infrastructure-dependent → MCP Remote独自実装」に該当。
27+
Engineに置くとCLI/MCP Localに不要な依存(Pillow for WebP変換)が入る。
28+
29+
`mcp-server/tools/compose.py` に配置。
30+
31+
```python
32+
def extract_optimized_defs(svg_path: Path) -> dict:
33+
"""Extract and optimize shared defs from LibreOffice SVG.
34+
35+
Processing:
36+
1. Strip <font>/<glyph> tags (keep font-family on text elements)
37+
2. Convert inline base64 PNG → base64 WebP
38+
39+
Returns:
40+
{
41+
"version": 1, # Schema version for forward compatibility
42+
"defs": str, # Optimized: fonts stripped, images as WebP base64
43+
}
44+
"""
45+
46+
def split_slide_components(svg_path: Path, slide_num: int) -> dict:
47+
"""Split LibreOffice SVG into per-component data (defs excluded).
48+
49+
Processing:
50+
1. Extract background from master page
51+
2. Split Page group into component fragments with metadata
52+
3. Convert inline base64 PNG → base64 WebP in component SVG fragments
53+
54+
Returns:
55+
{
56+
"version": 1, # Schema version for forward compatibility
57+
"viewBox": str,
58+
"bgFill": str, # Background fill color
59+
"bgSvg": str | None, # Background + BackgroundObjects SVG fragment
60+
"components": [
61+
{
62+
"class": str,
63+
"bbox": {"x": float, "y": float, "w": float, "h": float} | None,
64+
"text": str, # Preview text (first 80 chars)
65+
"svg": str, # SVG fragment (images converted to WebP base64)
66+
}
67+
]
68+
}
69+
"""
70+
```
71+
72+
### SVG最適化パイプライン
73+
74+
```
75+
Original defs (~1MB):
76+
<font>/<glyph>: 515KB (49%) → 除去
77+
base64 PNG: 508KB (49%) → WebP base64 36KB
78+
clipPath等: 24KB (2%) → そのまま
79+
─────────
80+
Optimized defs: ~60KB
81+
82+
フォント戦略:
83+
- <font>/<glyph> タグ除去(SVGフォント埋め込み)
84+
- テキスト要素の font-family="Amazon Ember Display" は維持
85+
- ブラウザにフォントがあれば正しく描画、なければフォールバック
86+
- 将来: @font-face + woff2 配信で拡張可能(font-family残存が基盤)
87+
88+
画像戦略:
89+
- data:image/png;base64,... → decode → WebP変換 → data:image/webp;base64,...
90+
- インライン維持(外部リクエスト不要、CORS不要)
91+
- 実測: PNG 351KB → WebP 12KB (3%)
92+
```
93+
94+
### 既存コードとの共有
95+
96+
- `sdpm.preview.measure`: SVG_NS, スライドグループ構造, BoundingBox取得 → 共通定数・ユーティリティ化
97+
- `split_svg_per_slide()` (measure.py内): スライド単位分割ロジック → compose でも同じ構造を走査
98+
- Phase 1実装時に共通化の範囲を判断する(過剰な事前リファクタリングは避ける)
99+
100+
## MCP Server: compose処理 + S3保存
101+
102+
`server.py``_run_measure()` 後に compose を同期実行。SVGは `_run_measure` で既に生成済み(`tmpdir/measure.svg`)なので追加のLibreOffice呼び出し不要。
103+
104+
MCPレスポンスにはcomposeデータを含めない(エージェントが使う情報ではない)。
105+
S3に保存し、WebUIがポーリングで取得する。既存のWebPプレビューと同じパターン。
106+
107+
```python
108+
# In run_python post-processing, after _run_measure
109+
# Note: save=False (dry-run for agent layout check) skips compose entirely.
110+
# tmpdir safety: compose runs within the same `with tempfile.TemporaryDirectory()` block
111+
# as _run_measure, so tmpdir/measure.svg is guaranteed to exist at this point.
112+
if measure_slides and save:
113+
from sdpm.preview.compose import extract_optimized_defs, split_slide_components
114+
try:
115+
defs_data = extract_optimized_defs(svg_path)
116+
_storage.put_json(f"decks/{deck_id}/compose/defs.json", defs_data)
117+
except Exception:
118+
logger.error("compose defs failed", exc_info=True)
119+
for slide_num in measure_slides:
120+
try:
121+
data = split_slide_components(svg_path, slide_num)
122+
key = f"decks/{deck_id}/compose/slide_{slide_num}.json"
123+
_storage.put_json(key, data)
124+
except Exception:
125+
logger.error("compose failed for slide %d", slide_num, exc_info=True)
126+
```
127+
128+
### データ伝搬経路
129+
130+
```
131+
Engine compose → S3保存
132+
├─ decks/{deck_id}/compose/defs.json ← デッキレベル共通defs (1回)
133+
└─ decks/{deck_id}/compose/slide_{N}.json ← スライド個別 (defs除外)
134+
135+
deck API (GET /decks/{id})
136+
├─ DeckDetail.defsUrl → defs.json presigned URL
137+
└─ SlidePreview.composeUrl → slide_{N}.json presigned URL
138+
139+
WebUI ポーリング (useWorkspace)
140+
├─ defsUrl変更検知 → defs再fetch
141+
└─ composeUrl変更検知 → AnimatedSlidePreview
142+
```
143+
144+
### deck API レスポンス拡張
145+
146+
`DeckDetail``defsUrl` フィールドを追加:
147+
```typescript
148+
export interface DeckDetail {
149+
// ... existing fields ...
150+
defsUrl: string | null // 共通defs presigned URL (新規)
151+
}
152+
```
153+
154+
`SlidePreview``composeUrl` フィールドを追加:
155+
```typescript
156+
export interface SlidePreview {
157+
slideId: string
158+
previewUrl: string | null // WebP (既存)
159+
composeUrl: string | null // 構造化JSON presigned URL (新規、defs除外)
160+
updatedAt: string
161+
slideJson?: string
162+
}
163+
```
164+
165+
presigned URLはポーリング時に毎回生成されるため、有効期限切れの問題は発生しない。
166+
`useWorkspace` の presigned URL安定化ロジックを composeUrl および defsUrl にも適用する。
167+
168+
## WebUI: AnimatedSlidePreview
169+
170+
New component: `web-ui/src/components/deck/AnimatedSlidePreview.tsx`
171+
172+
### 責務
173+
1. 構造化JSONからSVG DOMを組み立て(defs + bgSvg + components[].svg)
174+
2. メタデータ(bbox, class)でアニメーション制御
175+
3. エージェント種別の自動割当(フロント側責務)
176+
177+
### Props
178+
```typescript
179+
interface AnimatedSlidePreviewProps {
180+
defsUrl: string; // S3 presigned URL to shared defs JSON
181+
composeUrl: string; // S3 presigned URL to slide component JSON (defs excluded)
182+
slideId?: string; // For scroll targeting
183+
onComplete?: () => void; // Called when animation finishes
184+
fallback?: React.ReactNode; // Fallback when compose version mismatches or fetch fails
185+
}
186+
```
187+
188+
### SVG組み立て
189+
```
190+
fetch(defsUrl) → defs JSON (cached per polling cycle)
191+
fetch(composeUrl) → slide JSON
192+
193+
if (data.version !== COMPOSE_VERSION) → fallback to WebP thumbnail
194+
195+
<svg viewBox={data.viewBox}>
196+
{data.bgSvg ? <g innerHTML={sanitize(bgSvg)}/> : <rect fill={bgFill}/>}
197+
<g innerHTML={sanitize(defsData.defs)}/>
198+
{data.components.map(comp =>
199+
<g innerHTML={sanitize(comp.svg)} class="component" opacity={0}/>
200+
)}
201+
</svg>
202+
```
203+
204+
### 差分アニメーション(バックエンド diff)
205+
206+
変更があったコンポーネントのみアニメーションする。diff計算はバックエンド(server.py)で行い、
207+
各コンポーネントに `changed: boolean` フラグを付与する。フロントはフラグを参照するだけ。
208+
209+
**UX根拠**:
210+
- Motion Design Restraint: "One primary animation per interaction" — 変更していない箇所のアニメーションはノイズ
211+
- Purposeful Animation: アニメーションの目的は「AIが今ここを変更した」というフィードバック
212+
- Cognitive Load: ユーザーは「何が変わったのか」を即座に把握したい
213+
214+
**なぜバックエンドdiffか**:
215+
- compose JSONは毎回新しいepochでS3に保存される → フロントはURL変更だけでは内容変更を判別できない
216+
- フロントでのdiffは initialLoad判定・URL安定化・defsUrl変更時の再描画制御が複雑化した
217+
- バックエンドはS3に前回データがあり、PPTXソース(slides_json)も保持している → 正確なdiffが可能
218+
219+
**2段階diff**:
220+
221+
1. **スライドレベル突合(sourceHash)**: スライドJSONのmd5ハッシュでスライドの同一性を判定。
222+
番号ではなく内容ベースなので、挿入・削除・並べ替えに対応。
223+
```python
224+
sourceHash = md5(json.dumps(slide_json, sort_keys=True))
225+
```
226+
- 前回の全スライドを `{sourceHash: components_map}` のdictに格納
227+
- 新スライドのsourceHashで前回データをO(1)で引く
228+
- sourceHash不一致 or 前回なし → 全コンポーネント `changed: true`
229+
230+
2. **コンポーネントレベルdiff(class+bbox キー + text+class fingerprint)**:
231+
sourceHashが一致したスライド同士で、コンポーネント単位の変更を検出。
232+
- キー: `class+bbox` (Phase 0検証済み、false_neg=0)
233+
- fingerprint: `class|text` で内容変更を検出
234+
- キー不一致 or fingerprint不一致 → `changed: true`
235+
236+
**compose JSONスキーマ拡張**:
237+
```json
238+
{
239+
"version": 1,
240+
"sourceHash": "abc123...",
241+
"viewBox": "...",
242+
"components": [
243+
{
244+
"class": "TitleText",
245+
"bbox": {...},
246+
"text": "...",
247+
"svg": "...",
248+
"changed": true
249+
}
250+
]
251+
}
252+
```
253+
254+
**フロント側**: `comp.changed` を参照するだけ。diff検出・initialLoad判定・前回データ保持が不要。
255+
```typescript
256+
const animTargets = new Set<number>()
257+
data.components.forEach((comp, i) => {
258+
if (comp.changed) animTargets.add(i)
259+
})
260+
```
261+
262+
**composeクリーンナップ**: 新しいcompose JSONアップロード後、古いepochファイルを削除(WebPと同じパターン)。
263+
264+
### アニメーションフェーズ(PoCから移植)
265+
266+
アニメーション対象コンポーネントのみ実行。不変コンポーネントは即座にopacity=1で表示。
267+
268+
```
269+
Per changed/new component (stagger 420ms):
270+
Phase 1 (+0ms): カーソル飛来 → bbox左上に着地
271+
Phase 2 (+250ms): ワイヤーフレーム展開(左上→右下ドラッグ)+ カーソル右下移動
272+
Phase 3 (+450ms): マテリアライズ + タイプライター + カーソルフェードアウト
273+
274+
Unchanged components:
275+
即座に opacity=1 で表示(アニメーションなし)
276+
277+
Deleted components:
278+
フェードアウト (200ms, ease-out)
279+
```
280+
281+
### タイプライター実装
282+
- `textLength`/`lengthAdjust` を一時除去(文字引き伸ばし防止)
283+
- 文字数適応速度: 800ms/コンポーネント目標、15-50ms/文字でクランプ
284+
- 全文字完了後に `textLength`/`lengthAdjust` 復元
285+
- `useRef` でSVG DOM直接操作(React re-render回避)
286+
287+
### エージェント割当(フロント側)
288+
289+
割当は決定的でなければならない(ランダム禁止)。リプレイ・差分更新で色が変わると不自然。
290+
291+
```typescript
292+
const AGENTS = [
293+
{ name: 'Layout', color: '#3b6cf0' }, // TitleText, SubtitleText
294+
{ name: 'Content', color: '#2ba882' }, // text.length > 20
295+
{ name: 'Visual', color: '#8b5cf6' }, // Graphic, image
296+
{ name: 'Data', color: '#d97706' }, // ConnectorShape, line
297+
{ name: 'Decorator', color: '#e04070' }, // その他(フォールバック、ランダムにしない)
298+
];
299+
```
300+
301+
### SlideCarousel 統合
302+
```
303+
SlideCarousel (slidesタブ)
304+
├─ composeUrl あり → AnimatedSlidePreview 表示 (defsUrl + composeUrl)
305+
│ └─ onComplete → アニメーション完了(SVGのまま表示継続)
306+
└─ composeUrl なし → 従来のWebPサムネイル表示(SlideThumbnail)
307+
```
308+
309+
**自動スクロール**: composeUrl変更検知時、`changed: true` コンポーネントを持つ最初のスライドにスクロール。
310+
AnimatedSlidePreviewが `onAnimate` コールバックで通知 → SlideCarouselが最初の通知元にスクロール。
311+
312+
他コンポーネント(DeckCard, SearchResultsGrid, MentionPopup等)は変更なし。
313+
従来の `previewUrl`(WebP presigned URL)をそのまま使用。
314+
315+
### アクセシビリティ
316+
- `prefers-reduced-motion`: アニメーション無効時は即座に全表示
317+
318+
### タイプライターcleanup
319+
- スライド切替・アンマウント時に `useEffect` cleanup で `textLength`/`lengthAdjust` を復元
320+
- 復元漏れ防止のため、除去した属性値を `useRef` で保持
321+
322+
### セキュリティ
323+
- composeデータ(構造化JSON)は自前Engine生成のみ。外部ユーザー入力がSVGフラグメントに混入する経路は現時点でない
324+
- S3 presigned URL経由で取得するため、認証済みユーザーのみアクセス可能
325+
- `dangerouslySetInnerHTML` 使用箇所はcomposeデータに限定
326+
- 防御的措置: `innerHTML` 代入前に DOMPurify でサニタイズ(将来の入力経路追加に備えた多層防御)
327+
328+
## 削除対象
329+
330+
なし(WebPパイプラインは維持)
331+
332+
---
333+
**Created**: 2026-04-13

0 commit comments

Comments
 (0)