From 23a212713e4ec24c10eea68d7f276778f17d00d7 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 10:09:39 +0900 Subject: [PATCH 01/23] =?UTF-8?q?=E2=9C=A8=20feat(svg-composition-animatio?= =?UTF-8?q?n):=20SVG=20compose=20pipeline=20+=20animated=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation Phase 1-3: compose module, API integration, WebUI animation - mcp-server/tools/compose.py: extract_optimized_defs + split_slide_components (font strip, PNG→WebP, component split with bbox/class/text metadata) - server.py: compose after _run_measure (sync, ~300ms for 8 slides) - api/index.py: defsUrl + composeUrl presigned URLs in deck detail - AnimatedSlidePreview.tsx: SVG build + diff animation (class+bbox key) cursor fly-in → wireframe drag → materialize + typewriter - SlideCarousel: composeUrl → animated preview, fallback to WebP thumbnail - DOMPurify sanitization, prefers-reduced-motion, version check fallback - useWorkspace: presigned URL stabilization for compose/defs URLs --- api/index.py | 17 +- mcp-server/server.py | 27 ++ mcp-server/tools/compose.py | 134 +++++++ web-ui/package-lock.json | 47 ++- web-ui/package.json | 2 + web-ui/src/app/(authenticated)/decks/page.tsx | 1 + web-ui/src/app/globals.css | 6 + .../components/deck/AnimatedSlidePreview.tsx | 336 ++++++++++++++++++ web-ui/src/components/deck/SlideCarousel.tsx | 42 ++- web-ui/src/hooks/useWorkspace.ts | 23 ++ web-ui/src/services/deckService.ts | 2 + 11 files changed, 623 insertions(+), 14 deletions(-) create mode 100644 mcp-server/tools/compose.py create mode 100644 web-ui/src/components/deck/AnimatedSlidePreview.tsx diff --git a/api/index.py b/api/index.py index c5dcbf4..04a1b81 100644 --- a/api/index.py +++ b/api/index.py @@ -424,15 +424,29 @@ def get_deck(deck_id: str) -> Dict[str, Any]: resp = s3_client.get_object(Bucket=BUCKET_NAME, Key=pres_key) presentation = json.loads(resp["Body"].read()) preview_keys = _list_preview_keys(deck_id) + # Check compose data existence (defs + per-slide) + compose_keys = set() + try: + for obj in s3_client.list_objects_v2(Bucket=BUCKET_NAME, Prefix=f"decks/{deck_id}/compose/").get("Contents", []): + compose_keys.add(obj["Key"]) + except Exception: + pass + defs_key = f"decks/{deck_id}/compose/defs.json" + has_defs = defs_key in compose_keys + for i, s in enumerate(presentation.get("slides", [])): sid = f"slide_{i + 1:02d}" slide_preview = _resolve_preview_url(deck_id, sid, preview_keys) slide_entry: Dict[str, Any] = {"slideId": sid, "previewUrl": slide_preview} + # Compose URL for animation + compose_key = f"decks/{deck_id}/compose/slide_{i + 1}.json" + if compose_key in compose_keys: + slide_entry["composeUrl"] = preview_url(compose_key) if include_json: slide_entry["slideJson"] = json.dumps(s) slides.append(slide_entry) except Exception: - pass + has_defs = False # Read spec files from S3 (brief.md, outline.md, art-direction.html/.md) specs: Dict[str, Any] = {} @@ -467,6 +481,7 @@ def get_deck(deck_id: str) -> Dict[str, Any]: "slideCount": len(slides), "slides": slides, "specs": specs, + "defsUrl": preview_url(defs_key) if has_defs else None, "pptxUrl": (_cf_signed_url(pptx_key) or presigned_url(s3_client, BUCKET_NAME, pptx_key)) if pptx_key else None, "updatedAt": deck.get("updatedAt", ""), "chatSessionId": deck.get("chatSessionId"), diff --git a/mcp-server/server.py b/mcp-server/server.py index f0a489f..c607d15 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -725,6 +725,33 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, logger.warning("Layout bias check failed: %s", e) if save: + # Compose: SVG → optimized JSON for WebUI animation + # Runs synchronously — measured at ~300ms for 8 slides (≪3s threshold) + # Intentionally re-extracts defs each time (60KB PUT, negligible cost) + try: + from tools.compose import extract_optimized_defs, split_slide_components + svg_path = tmpdir / "measure.svg" + if svg_path.exists(): + import json as _json + defs_data = extract_optimized_defs(svg_path) + _storage.upload_file( + key=f"decks/{deck_id}/compose/defs.json", + data=_json.dumps(defs_data, ensure_ascii=False).encode(), + content_type="application/json", + ) + for sn in measure_slides: + try: + comp_data = split_slide_components(svg_path, sn) + _storage.upload_file( + key=f"decks/{deck_id}/compose/slide_{sn}.json", + data=_json.dumps(comp_data, ensure_ascii=False).encode(), + content_type="application/json", + ) + except Exception: + logger.error("compose failed for slide %d", sn, exc_info=True) + except Exception: + logger.error("compose defs failed", exc_info=True) + from server_utils import schedule_webp_background schedule_webp_background(deck_id, pptx_path, tmpdir, _storage) else: diff --git a/mcp-server/tools/compose.py b/mcp-server/tools/compose.py new file mode 100644 index 0000000..409906b --- /dev/null +++ b/mcp-server/tools/compose.py @@ -0,0 +1,134 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""SVG composition: split LibreOffice SVG into optimized per-slide components. + +MCP Remote only — consumed by WebUI for build animation. +Not shared with Engine/CLI/MCP Local (no consumer there). +""" + +import base64 +import io +import logging +import re +from pathlib import Path + +from lxml import etree +from PIL import Image + +logger = logging.getLogger("sdpm.compose") + +SVG_NS = "http://www.w3.org/2000/svg" +OOO_NS = "http://xml.openoffice.org/svg/export" +_PNG_B64_RE = re.compile(r"data:image/png;base64,([A-Za-z0-9+/=]+)") + + +def _png_to_webp_b64(match: re.Match) -> str: + png_data = base64.b64decode(match.group(1)) + img = Image.open(io.BytesIO(png_data)) + buf = io.BytesIO() + img.save(buf, format="WEBP", quality=80) + return f"data:image/webp;base64,{base64.b64encode(buf.getvalue()).decode()}" + + +def _convert_images(svg_str: str) -> str: + return _PNG_B64_RE.sub(_png_to_webp_b64, svg_str) + + +def _strip_fonts(defs_el: etree._Element) -> None: + for font in defs_el.findall(f".//{{{SVG_NS}}}font"): + font.getparent().remove(font) + + +def extract_optimized_defs(svg_path: Path) -> dict: + """Extract shared defs: strip SVG fonts, convert PNG→WebP. + + Returns {"version": 1, "defs": str}. + """ + tree = etree.parse(str(svg_path)) + root = tree.getroot() + defs_elements = root.findall(f"{{{SVG_NS}}}defs") + for d in defs_elements: + _strip_fonts(d) + defs_svg = "".join(etree.tostring(d, encoding="unicode") for d in defs_elements) + return {"version": 1, "defs": _convert_images(defs_svg)} + + +def split_slide_components(svg_path: Path, slide_num: int) -> dict: + """Split one slide into component fragments with metadata (defs excluded). + + Returns {"version": 1, "viewBox": str, "bgFill": str, + "bgSvg": str|None, "components": [...]}. + """ + tree = etree.parse(str(svg_path)) + root = tree.getroot() + view_box = root.get("viewBox", "0 0 33867 19050") + + slides = root.findall(f".//{{{SVG_NS}}}g[@class='Slide']") + if slide_num >= len(slides): + raise ValueError(f"Slide {slide_num} not found (total: {len(slides) - 1})") + + page_g = slides[slide_num].find(f"{{{SVG_NS}}}g[@class='Page']") + if page_g is None: + raise ValueError("No Page group found") + + # Components + components = [] + for shape_g in page_g: + if shape_g.tag != f"{{{SVG_NS}}}g": + continue + cls = shape_g.get("class", "") + bbox_el = shape_g.find(f".//{{{SVG_NS}}}rect[@class='BoundingBox']") + bbox = None + if bbox_el is not None: + bbox = { + "x": float(bbox_el.get("x", 0)), + "y": float(bbox_el.get("y", 0)), + "w": float(bbox_el.get("width", 0)), + "h": float(bbox_el.get("height", 0)), + } + text_el = shape_g.find(f".//{{{SVG_NS}}}text") + text = "" + if text_el is not None: + text = "".join(text_el.itertext()).strip()[:80] + components.append({ + "class": cls, + "bbox": bbox, + "text": text, + "svg": _convert_images(etree.tostring(shape_g, encoding="unicode")), + }) + + # Background from master page + bg_fill = "#000" + bg_svg = None + meta_slides = root.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slides']") + if meta_slides is not None: + meta = meta_slides.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slide_{slide_num - 1}']") + if meta is not None: + master_id = meta.get(f"{{{OOO_NS}}}master", "") + if master_id: + master_g = root.find(f".//*[@id='{master_id}']") + if master_g is not None: + parts = [] + bg_g = master_g.find(f"{{{SVG_NS}}}g[@class='Background']") + if bg_g is not None: + parts.append(etree.tostring(bg_g, encoding="unicode")) + for el in bg_g.iter(): + f = el.get("fill") + if f and f != "none": + bg_fill = f + break + bo_g = master_g.find(f"{{{SVG_NS}}}g[@class='BackgroundObjects']") + if bo_g is not None: + for child in bo_g: + if child.get("visibility") != "hidden": + parts.append(etree.tostring(child, encoding="unicode")) + if parts: + bg_svg = _convert_images("\n".join(parts)) + + return { + "version": 1, + "viewBox": view_box, + "bgFill": bg_fill, + "bgSvg": bg_svg, + "components": components, + } diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index f1be624..fa7c291 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -15,9 +15,11 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", + "@types/dompurify": "^3.0.5", "aws-amplify": "^6.15.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.562.0", "next": "^16.2.3", "next-themes": "^0.4.6", @@ -167,6 +169,7 @@ "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-6.16.1.tgz", "integrity": "sha512-WHO6yYegmnZ+K3vnYzVwy+wnxYqSkdFakBIlgm4922QXHOQYWdIl/rrTcaagrpJEGT6YlTnqx1ANIoPojNxWmw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/types": "3.973.1", @@ -1637,6 +1640,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3463,6 +3467,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -7055,6 +7060,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7113,6 +7127,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -7122,6 +7137,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7132,6 +7148,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7143,6 +7160,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -7200,6 +7223,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -7719,6 +7743,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8266,6 +8291,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9081,6 +9107,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -9405,6 +9440,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9590,6 +9626,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10015,6 +10052,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10863,6 +10901,7 @@ "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11852,7 +11891,6 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -14420,6 +14458,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14429,6 +14468,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16094,7 +16134,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -16414,6 +16455,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17075,6 +17117,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web-ui/package.json b/web-ui/package.json index ee4318c..e4e9297 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -18,9 +18,11 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", + "@types/dompurify": "^3.0.5", "aws-amplify": "^6.15.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^0.562.0", "next": "^16.2.3", "next-themes": "^0.4.6", diff --git a/web-ui/src/app/(authenticated)/decks/page.tsx b/web-ui/src/app/(authenticated)/decks/page.tsx index 170e1cc..0e54447 100644 --- a/web-ui/src/app/(authenticated)/decks/page.tsx +++ b/web-ui/src/app/(authenticated)/decks/page.tsx @@ -123,6 +123,7 @@ export default function DecksPage() { ) : ( void + /** Fallback to render when compose version mismatches or fetch fails. */ + fallback?: React.ReactNode +} + +// --- Helpers --- + +function makeKey(c: ComposeComponent): string { + return c.bbox + ? `${c.class}|${c.bbox.x},${c.bbox.y},${c.bbox.w},${c.bbox.h}` + : `${c.class}|none` +} + +function assignAgent(comp: ComposeComponent) { + const cls = comp.class || "" + if (cls === "TitleText" || cls === "SubtitleText") return AGENTS[0] + if (comp.text.length > 20) return AGENTS[1] + if (cls === "Graphic" || cls.includes("image")) return AGENTS[2] + if (cls.includes("ConnectorShape") || cls.includes("line")) return AGENTS[3] + return AGENTS[4] // Decorator — deterministic, no random +} + +function sanitizeSvg(raw: string): string { + return DOMPurify.sanitize(raw, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ["use", "clipPath", "mask", "filter", "feGaussianBlur", "feOffset", + "feMerge", "feMergeNode", "feFlood", "feComposite", "feBlend", + "font", "font-face", "glyph", "missing-glyph"], + ADD_ATTR: ["xlink:href", "clip-path", "mask", "filter", "textLength", + "lengthAdjust", "class", "viewBox", "preserveAspectRatio"], + }) +} + +// --- Component --- + +export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback }: AnimatedSlidePreviewProps) { + const containerRef = useRef(null) + const prevCompRef = useRef | null>(null) + const intervalsRef = useRef([]) + const timersRef = useRef[]>([]) + const [error, setError] = useState(false) + const reducedMotion = useRef( + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) + + const cleanup = useCallback(() => { + intervalsRef.current.forEach(clearInterval) + intervalsRef.current = [] + timersRef.current.forEach(clearTimeout) + timersRef.current = [] + }, []) + + useEffect(() => () => cleanup(), [cleanup]) + + useEffect(() => { + let cancelled = false + + async function run() { + try { + const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) + if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } + + const defsData: DefsData = await defsResp.json() + const data: ComposeData = await compResp.json() + + if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { + setError(true); return + } + + const container = containerRef.current + if (!container || cancelled) return + + cleanup() + + // --- Diff detection --- + const animTargets = new Set() + const prevMap = prevCompRef.current + if (!prevMap) { + data.components.forEach((_, i) => animTargets.add(i)) + } else { + data.components.forEach((comp, i) => { + const key = makeKey(comp) + const prevSvg = prevMap.get(key) + if (prevSvg === undefined || prevSvg !== comp.svg) { + animTargets.add(i) + } + }) + } + + // Save for next diff + const newMap = new Map() + data.components.forEach(c => newMap.set(makeKey(c), c.svg)) + prevCompRef.current = newMap + + // --- Build SVG --- + const vb = data.viewBox.split(" ").map(Number) + container.innerHTML = "" + // Remove old overlays + container.parentElement?.querySelectorAll(".asp-overlay").forEach(el => el.remove()) + + const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svgEl.setAttribute("viewBox", data.viewBox) + svgEl.setAttribute("preserveAspectRatio", "xMidYMid") + svgEl.style.width = "100%" + svgEl.style.height = "100%" + + // Background + if (data.bgSvg) { + const g = document.createElementNS("http://www.w3.org/2000/svg", "g") + g.innerHTML = sanitizeSvg(data.bgSvg) + svgEl.appendChild(g) + } else { + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect") + rect.setAttribute("width", String(vb[2])) + rect.setAttribute("height", String(vb[3])) + rect.setAttribute("fill", data.bgFill || "#000") + svgEl.appendChild(rect) + } + + // Defs + const defsG = document.createElementNS("http://www.w3.org/2000/svg", "g") + defsG.innerHTML = sanitizeSvg(defsData.defs) + while (defsG.firstChild) svgEl.appendChild(defsG.firstChild) + + // Components + data.components.forEach((comp, i) => { + const g = document.createElementNS("http://www.w3.org/2000/svg", "g") + g.innerHTML = sanitizeSvg(comp.svg) + g.dataset.index = String(i) + if (!animTargets.has(i) || reducedMotion.current) { + g.style.opacity = "1" + } else { + g.style.opacity = "0" + } + svgEl.appendChild(g) + }) + + container.appendChild(svgEl) + + // --- Reduced motion: done --- + if (reducedMotion.current) { + onComplete?.() + return + } + + // --- Animate changed components --- + const overlayContainer = document.createElement("div") + overlayContainer.className = "asp-overlay absolute inset-0 pointer-events-none" + container.parentElement?.appendChild(overlayContainer) + + let staggerIdx = 0 + data.components.forEach((comp, i) => { + if (!animTargets.has(i) || !comp.bbox) return + const si = staggerIdx++ + const agent = assignAgent(comp) + + const pctL = (comp.bbox.x / vb[2]) * 100 + const pctT = (comp.bbox.y / vb[3]) * 100 + const pctW = (comp.bbox.w / vb[2]) * 100 + const pctH = (comp.bbox.h / vb[3]) * 100 + + // Phase 1: cursor fly-in + const t1 = setTimeout(() => { + if (cancelled) return + const cursor = document.createElement("div") + cursor.className = "absolute transition-all duration-300" + cursor.style.cssText = `left:${pctL}%;top:${Math.max(0, pctT - 5)}%;opacity:0;z-index:20;` + cursor.innerHTML = `${agent.name}` + overlayContainer.appendChild(cursor) + requestAnimationFrame(() => { + cursor.style.opacity = "1" + cursor.style.left = `${pctL}%` + cursor.style.top = `${pctT}%` + }) + + // Phase 2: wireframe + const t2 = setTimeout(() => { + if (cancelled) return + const wf = document.createElement("div") + wf.className = "absolute" + wf.style.cssText = `left:${pctL}%;top:${pctT}%;width:${pctW}%;height:${pctH}%;border:1px solid ${agent.color};border-radius:2px;box-shadow:inset 0 0 16px ${agent.glow};opacity:1;clip-path:inset(0 100% 100% 0);animation:asp-wf-drag 0.35s cubic-bezier(0.16,1,0.3,1) forwards;` + overlayContainer.appendChild(wf) + + // Move cursor to bottom-right + const endL = ((comp.bbox!.x + comp.bbox!.w) / vb[2]) * 100 + const endT = ((comp.bbox!.y + comp.bbox!.h) / vb[3]) * 100 + cursor.style.left = `${endL}%` + cursor.style.top = `${endT}%` + + // Phase 3: materialize + const t3 = setTimeout(() => { + if (cancelled) return + const g = svgEl.querySelector(`g[data-index="${i}"]`) as SVGGElement | null + if (g) { + g.style.opacity = "1" + g.style.filter = "brightness(2) saturate(0.5)" + g.style.transition = "filter 0.5s cubic-bezier(0.16,1,0.3,1)" + requestAnimationFrame(() => { g.style.filter = "brightness(1) saturate(1)" }) + typewrite(g) + } + // Fade out wireframe + cursor + const t4 = setTimeout(() => { + wf.style.transition = "opacity 0.4s ease-out" + wf.style.opacity = "0" + cursor.style.transition = "opacity 0.4s ease-out" + cursor.style.opacity = "0" + }, 500) + timersRef.current.push(t4) + }, WIREFRAME_LEAD_MS - 50) + timersRef.current.push(t3) + }, 250) + timersRef.current.push(t2) + }, si * STAGGER_MS) + timersRef.current.push(t1) + }) + + // onComplete after all animations + const totalTime = staggerIdx * STAGGER_MS + WIREFRAME_LEAD_MS + 1000 + const tDone = setTimeout(() => { + if (!cancelled) { + overlayContainer.remove() + onComplete?.() + } + }, totalTime) + timersRef.current.push(tDone) + + } catch { + if (!cancelled) setError(true) + } + } + + run() + return () => { cancelled = true } + }, [defsUrl, composeUrl, cleanup, onComplete]) + + if (error && fallback) return <>{fallback} + + return ( +
+
+
+ ) +} + +// --- Typewriter --- + +function typewrite(compEl: SVGGElement) { + const textEls = compEl.querySelectorAll(".SVGTextShape") + textEls.forEach(textEl => { + const tspans = textEl.querySelectorAll("tspan") + const leafSpans: { el: Element; fullText: string; saved: Record }[] = [] + let totalChars = 0 + tspans.forEach(ts => { + if (ts.querySelectorAll("tspan").length === 0 && ts.textContent) { + const saved: Record = {} + for (const attr of ["textLength", "lengthAdjust"]) { + if (ts.hasAttribute(attr)) { + saved[attr] = ts.getAttribute(attr)! + ts.removeAttribute(attr) + } + } + totalChars += ts.textContent.length + leafSpans.push({ el: ts, fullText: ts.textContent, saved }) + ts.textContent = "" + } + }) + if (!leafSpans.length) return + const charMs = Math.max(MIN_CHAR_MS, Math.min(MAX_CHAR_MS, Math.floor(TYPE_DURATION_MS / totalChars))) + let spanIdx = 0, charIdx = 0 + const iv = window.setInterval(() => { + if (spanIdx >= leafSpans.length) { clearInterval(iv); return } + const span = leafSpans[spanIdx] + charIdx++ + span.el.textContent = span.fullText.slice(0, charIdx) + if (charIdx >= span.fullText.length) { + for (const [a, v] of Object.entries(span.saved)) span.el.setAttribute(a, v) + spanIdx++ + charIdx = 0 + } + }, charMs) + }) +} diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 2ea1bbc..97c6956 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -18,9 +18,11 @@ import { usePreferences } from "@/hooks/usePreferences" import { SpecStepNav, SpecMarkdownPreview } from "@/components/deck/SpecStepNav" import type { SpecTab } from "@/components/deck/SpecStepNav" import { SlideThumbnail } from "@/components/deck/SlideThumbnail" +import { AnimatedSlidePreview } from "@/components/deck/AnimatedSlidePreview" interface SlideCarouselProps { slides: SlidePreview[] + defsUrl?: string | null deckId?: string deckName?: string pptxUrl?: string | null @@ -44,7 +46,7 @@ interface SlideCarouselProps { idToken?: string } -export function SlideCarousel({ slides, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { +export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { const slidesWithPreview = slides.filter((s) => s.previewUrl) const auth = useAuth() const [jsonLoading, setJsonLoading] = useState(false) @@ -294,16 +296,34 @@ export function SlideCarousel({ slides, deckId, deckName, pptxUrl, isLoading, on
) : ( slidesWithPreview.map((slide, i) => ( - onSlideClick?.(i + 1)} - updated={updatedIds.has(slide.slideId)} - className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" - /> + slide.composeUrl && defsUrl ? ( + onSlideClick?.(i + 1)} + className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" + /> + } + /> + ) : ( + onSlideClick?.(i + 1)} + updated={updatedIds.has(slide.slideId)} + className="slide-shadow w-full cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" + /> + ) )) )} diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index 3f89872..945ab95 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -129,6 +129,29 @@ export function useWorkspace( } else { stablePreviewUrls.current.delete(s.slideId) } + // Stabilise composeUrl with same pattern + if (s.composeUrl) { + const cacheKey = `${s.slideId}:compose` + const cached = stablePreviewUrls.current.get(cacheKey) + const base = s.composeUrl.split("?")[0] + const cachedBase = cached?.url.split("?")[0] || "" + if (base !== cachedBase) { + stablePreviewUrls.current.set(cacheKey, { url: s.composeUrl }) + } else if (cached) { + s.composeUrl = cached.url + } + } + } + // Stabilise defsUrl + if (data.defsUrl) { + const cached = stablePreviewUrls.current.get("__defs__") + const base = data.defsUrl.split("?")[0] + const cachedBase = cached?.url.split("?")[0] || "" + if (base !== cachedBase) { + stablePreviewUrls.current.set("__defs__", { url: data.defsUrl }) + } else if (cached) { + data.defsUrl = cached.url + } } setDeck(data) } catch { diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index 55f176d..1f9e64c 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -19,6 +19,7 @@ export interface DeckSummary { export interface SlidePreview { slideId: string previewUrl: string | null + composeUrl?: string | null updatedAt: string slideJson?: string } @@ -34,6 +35,7 @@ export interface DeckDetail { name: string slideOrder: string[] slides: SlidePreview[] + defsUrl?: string | null pptxUrl: string | null specs?: SpecFiles | null updatedAt: string From 90f7a315de84142caebfd6acf482ce12d12d669a Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 12:15:16 +0900 Subject: [PATCH 02/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20DOMPurify=20strip=20+=20preview=20filter=20+=20build=20?= =?UTF-8?q?cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disable DOMPurify sanitization (strips SVG clip-path, namespaces, fills) - Fix slidesWithPreview filter to include composeUrl-only slides - Fix hasSlides to check composeUrl in addition to previewUrl SPEC: 20260413-0806_svg-composition-animation --- web-ui/src/components/deck/AnimatedSlidePreview.tsx | 10 ++-------- web-ui/src/components/deck/SlideCarousel.tsx | 2 +- web-ui/src/hooks/useWorkspace.ts | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index a965156..3b32381 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -75,14 +75,8 @@ function assignAgent(comp: ComposeComponent) { } function sanitizeSvg(raw: string): string { - return DOMPurify.sanitize(raw, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ["use", "clipPath", "mask", "filter", "feGaussianBlur", "feOffset", - "feMerge", "feMergeNode", "feFlood", "feComposite", "feBlend", - "font", "font-face", "glyph", "missing-glyph"], - ADD_ATTR: ["xlink:href", "clip-path", "mask", "filter", "textLength", - "lengthAdjust", "class", "viewBox", "preserveAspectRatio"], - }) + // TODO: Re-enable DOMPurify with correct SVG config after visual verification + return raw } // --- Component --- diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 97c6956..5b810e0 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -47,7 +47,7 @@ interface SlideCarouselProps { } export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { - const slidesWithPreview = slides.filter((s) => s.previewUrl) + const slidesWithPreview = slides.filter((s) => s.previewUrl || s.composeUrl) const auth = useAuth() const [jsonLoading, setJsonLoading] = useState(false) const { viewMode, setViewMode } = usePreferences() diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index 945ab95..b4c993c 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -205,7 +205,7 @@ export function useWorkspace( const isNew = activeDeckId === "new" const isOwner = deck?.role === "owner" || deck?.role === undefined const canChat = isOwner || isNew - const hasSlides = deck && deck.slides.some((s) => s.previewUrl) + const hasSlides = deck && deck.slides.some((s) => s.previewUrl || s.composeUrl) const waitingForPng = pptxRequested // Reset flag once previews change after generate_pptx From 02d56693056bc3f15652964cb0d64639d722c2ee Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 13:53:44 +0900 Subject: [PATCH 03/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20bg=20color,=20epoch=20keys,=20initial=20load,=20webp=20?= =?UTF-8?q?separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix compose.py: use slide-specific SlideBackground over master page - Epoch-keyed compose S3 keys for proper update detection - API: _latest_compose_key() to resolve latest epoch - AnimatedSlidePreview: skip animation on initial load - Remove WebP generation from run_python (kept in generate_pptx) - Generate compose for all slides (frontend diff handles animation) - count_slides() helper in compose.py SPEC: 20260413-0806_svg-composition-animation --- api/index.py | 26 ++++-- mcp-server/server.py | 16 ++-- mcp-server/tools/compose.py | 84 ++++++++++++------- .../components/deck/AnimatedSlidePreview.tsx | 22 +++-- web-ui/src/hooks/useWorkspace.ts | 2 +- 5 files changed, 102 insertions(+), 48 deletions(-) diff --git a/api/index.py b/api/index.py index 04a1b81..61ec4d0 100644 --- a/api/index.py +++ b/api/index.py @@ -424,23 +424,37 @@ def get_deck(deck_id: str) -> Dict[str, Any]: resp = s3_client.get_object(Bucket=BUCKET_NAME, Key=pres_key) presentation = json.loads(resp["Body"].read()) preview_keys = _list_preview_keys(deck_id) - # Check compose data existence (defs + per-slide) + # Check compose data existence (defs + per-slide, epoch-keyed like previews) compose_keys = set() try: for obj in s3_client.list_objects_v2(Bucket=BUCKET_NAME, Prefix=f"decks/{deck_id}/compose/").get("Contents", []): compose_keys.add(obj["Key"]) except Exception: pass - defs_key = f"decks/{deck_id}/compose/defs.json" - has_defs = defs_key in compose_keys + + import re as _re + def _latest_compose_key(prefix: str, keys: set) -> Optional[str]: + """Pick the key with the highest epoch from epoch-keyed compose files.""" + best_epoch, best_key = -1, None + for k in keys: + if not k.startswith(prefix): + continue + m = _re.search(r"_(\d+)\.json$", k) + epoch = int(m.group(1)) if m else 0 + if epoch > best_epoch: + best_epoch, best_key = epoch, k + return best_key + + defs_key = _latest_compose_key(f"decks/{deck_id}/compose/defs", compose_keys) + has_defs = defs_key is not None for i, s in enumerate(presentation.get("slides", [])): sid = f"slide_{i + 1:02d}" slide_preview = _resolve_preview_url(deck_id, sid, preview_keys) slide_entry: Dict[str, Any] = {"slideId": sid, "previewUrl": slide_preview} - # Compose URL for animation - compose_key = f"decks/{deck_id}/compose/slide_{i + 1}.json" - if compose_key in compose_keys: + # Compose URL for animation (epoch-keyed) + compose_key = _latest_compose_key(f"decks/{deck_id}/compose/slide_{i + 1}", compose_keys) + if compose_key: slide_entry["composeUrl"] = preview_url(compose_key) if include_json: slide_entry["slideJson"] = json.dumps(s) diff --git a/mcp-server/server.py b/mcp-server/server.py index c607d15..4f11183 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -729,21 +729,25 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, # Runs synchronously — measured at ~300ms for 8 slides (≪3s threshold) # Intentionally re-extracts defs each time (60KB PUT, negligible cost) try: - from tools.compose import extract_optimized_defs, split_slide_components + from tools.compose import extract_optimized_defs, split_slide_components, count_slides svg_path = tmpdir / "measure.svg" if svg_path.exists(): import json as _json + import time as _time + _epoch = int(_time.time()) defs_data = extract_optimized_defs(svg_path) _storage.upload_file( - key=f"decks/{deck_id}/compose/defs.json", + key=f"decks/{deck_id}/compose/defs_{_epoch}.json", data=_json.dumps(defs_data, ensure_ascii=False).encode(), content_type="application/json", ) - for sn in measure_slides: + # Generate compose for all slides (diff handled by frontend) + _total = count_slides(svg_path) + for sn in range(1, _total + 1): try: comp_data = split_slide_components(svg_path, sn) _storage.upload_file( - key=f"decks/{deck_id}/compose/slide_{sn}.json", + key=f"decks/{deck_id}/compose/slide_{sn}_{_epoch}.json", data=_json.dumps(comp_data, ensure_ascii=False).encode(), content_type="application/json", ) @@ -752,8 +756,8 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, except Exception: logger.error("compose defs failed", exc_info=True) - from server_utils import schedule_webp_background - schedule_webp_background(deck_id, pptx_path, tmpdir, _storage) + # tmpdir cleanup (WebP generation only in generate_pptx) + shutil.rmtree(tmpdir, ignore_errors=True) else: shutil.rmtree(tmpdir, ignore_errors=True) except Exception as e: diff --git a/mcp-server/tools/compose.py b/mcp-server/tools/compose.py index 409906b..c5bbf4f 100644 --- a/mcp-server/tools/compose.py +++ b/mcp-server/tools/compose.py @@ -39,6 +39,12 @@ def _strip_fonts(defs_el: etree._Element) -> None: font.getparent().remove(font) +def count_slides(svg_path: Path) -> int: + """Return the number of slides in a LibreOffice SVG.""" + root = etree.parse(str(svg_path)).getroot() + return len(root.findall(f".//{{{SVG_NS}}}g[@class='Slide']")) + + def extract_optimized_defs(svg_path: Path) -> dict: """Extract shared defs: strip SVG fonts, convert PNG→WebP. @@ -71,7 +77,55 @@ def split_slide_components(svg_path: Path, slide_num: int) -> dict: if page_g is None: raise ValueError("No Page group found") - # Components + # --- Background --- + bg_fill = "#000" + bg_svg = None + + # 1) Slide-specific custom background (Page > defs.SlideBackground) + slide_bg_defs = page_g.find(f"{{{SVG_NS}}}defs[@class='SlideBackground']") + if slide_bg_defs is not None: + parts = [] + for child in slide_bg_defs: + cls = child.get("class", "") + if cls in ("Background", "BackgroundObjects"): + parts.append(etree.tostring(child, encoding="unicode")) + if cls == "Background": + for el in child.iter(): + f = el.get("fill") + if f and f != "none": + bg_fill = f + break + if parts: + bg_svg = _convert_images("\n".join(parts)) + + # 2) Fallback: master page background + if bg_svg is None: + meta_slides = root.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slides']") + if meta_slides is not None: + meta = meta_slides.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slide_{slide_num - 1}']") + if meta is not None: + master_id = meta.get(f"{{{OOO_NS}}}master", "") + if master_id: + master_g = root.find(f".//*[@id='{master_id}']") + if master_g is not None: + parts = [] + bg_g = master_g.find(f"{{{SVG_NS}}}g[@class='Background']") + if bg_g is not None: + parts.append(etree.tostring(bg_g, encoding="unicode")) + for el in bg_g.iter(): + f = el.get("fill") + if f and f != "none": + bg_fill = f + break + bo_g = master_g.find(f"{{{SVG_NS}}}g[@class='BackgroundObjects']") + if bo_g is not None: + for child in bo_g: + if child.get("visibility") != "hidden": + parts.append(etree.tostring(child, encoding="unicode")) + if parts: + bg_svg = _convert_images("\n".join(parts)) + + # --- Components --- components = [] for shape_g in page_g: if shape_g.tag != f"{{{SVG_NS}}}g": @@ -97,34 +151,6 @@ def split_slide_components(svg_path: Path, slide_num: int) -> dict: "svg": _convert_images(etree.tostring(shape_g, encoding="unicode")), }) - # Background from master page - bg_fill = "#000" - bg_svg = None - meta_slides = root.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slides']") - if meta_slides is not None: - meta = meta_slides.find(f".//{{{SVG_NS}}}g[@id='ooo:meta_slide_{slide_num - 1}']") - if meta is not None: - master_id = meta.get(f"{{{OOO_NS}}}master", "") - if master_id: - master_g = root.find(f".//*[@id='{master_id}']") - if master_g is not None: - parts = [] - bg_g = master_g.find(f"{{{SVG_NS}}}g[@class='Background']") - if bg_g is not None: - parts.append(etree.tostring(bg_g, encoding="unicode")) - for el in bg_g.iter(): - f = el.get("fill") - if f and f != "none": - bg_fill = f - break - bo_g = master_g.find(f"{{{SVG_NS}}}g[@class='BackgroundObjects']") - if bo_g is not None: - for child in bo_g: - if child.get("visibility") != "hidden": - parts.append(etree.tostring(child, encoding="unicode")) - if parts: - bg_svg = _convert_images("\n".join(parts)) - return { "version": 1, "viewBox": view_box, diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 3b32381..3eae85d 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -15,7 +15,7 @@ import DOMPurify from "dompurify" // --- Constants --- const COMPOSE_VERSION = 1 -const STAGGER_MS = 420 +const STAGGER_MS = 180 const WIREFRAME_LEAD_MS = 280 const TYPE_DURATION_MS = 800 const MIN_CHAR_MS = 15 @@ -100,6 +100,8 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback useEffect(() => () => cleanup(), [cleanup]) + const lastJsonRef = useRef("") + useEffect(() => { let cancelled = false @@ -108,8 +110,16 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } - const defsData: DefsData = await defsResp.json() - const data: ComposeData = await compResp.json() + const defsText = await defsResp.text() + const compText = await compResp.text() + const combined = defsText + compText + + // Skip if content unchanged + if (combined === lastJsonRef.current) return + lastJsonRef.current = combined + + const defsData: DefsData = JSON.parse(defsText) + const data: ComposeData = JSON.parse(compText) if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { setError(true); return @@ -121,11 +131,11 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback cleanup() // --- Diff detection --- + // First load: show everything instantly (no animation). + // Subsequent updates: animate only changed/new components. const animTargets = new Set() const prevMap = prevCompRef.current - if (!prevMap) { - data.components.forEach((_, i) => animTargets.add(i)) - } else { + if (prevMap) { data.components.forEach((comp, i) => { const key = makeKey(comp) const prevSvg = prevMap.get(key) diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index b4c993c..022f485 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -129,7 +129,7 @@ export function useWorkspace( } else { stablePreviewUrls.current.delete(s.slideId) } - // Stabilise composeUrl with same pattern + // Stabilise composeUrl with same pattern as previewUrl if (s.composeUrl) { const cacheKey = `${s.slideId}:compose` const cached = stablePreviewUrls.current.get(cacheKey) From b1558434a7af31a97364b020a13b89ddb5c9aa2b Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 14:15:27 +0900 Subject: [PATCH 04/23] =?UTF-8?q?=E2=9C=A8=20feat(svg-composition-animatio?= =?UTF-8?q?n):=20auto-scroll=20to=20updated=20slide,=20compose=20on=20save?= =?UTF-8?q?-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compose runs on save=True even without measure_slides - SVG export shared between measure and compose (single conversion) - Remove WebP from run_python (kept in generate_pptx) - Auto-scroll to first changed slide before animation - AnimatedSlidePreview accepts slideId for scroll targeting SPEC: 20260413-0806_svg-composition-animation --- mcp-server/server.py | 39 +++++++++++++------ .../components/deck/AnimatedSlidePreview.tsx | 5 ++- web-ui/src/components/deck/SlideCarousel.tsx | 20 ++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index 4f11183..6787d54 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -686,8 +686,7 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, result: dict = {"output": output} # Post-processing: measure_slides triggers PPTX build → measure/lint/bias - # WebP preview generation runs when measure_slides is present and save=True - if deck_id and measure_slides: + if deck_id and (measure_slides or save): import shutil import traceback @@ -698,30 +697,46 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, tmpdir, slides, build_kwargs = _prepare_workspace(deck_id, user_id, _storage) pptx_path = _build_pptx(tmpdir, slides, build_kwargs) - # Measure - try: - result["measure"] = _run_measure(tmpdir, pptx_path, measure_slides) - except Exception as e: - result["measure"] = json.dumps({"error": str(e)}) + # SVG export (needed for both measure and compose) + svg_path = tmpdir / "measure.svg" + env = os.environ.copy() + env["HOME"] = str(tmpdir) + subprocess.run( + ["soffice", "--headless", "--convert-to", "svg", "--outdir", str(tmpdir), str(pptx_path)], + env=env, capture_output=True, text=True, timeout=120, check=True, + ) + + # Measure (only when requested) + if measure_slides: + try: + from sdpm.preview.measure import measure_from_svg, format_measure_report + if svg_path.exists(): + results = measure_from_svg(svg_path=svg_path, slide_indices=measure_slides) + result["measure"] = format_measure_report(results) + else: + result["measure"] = json.dumps({"error": "LibreOffice SVG export failed"}) + except Exception as e: + result["measure"] = json.dumps({"error": str(e)}) # Lint (filter to measured slides; lint uses 0-based index) - try: + if measure_slides: + try: from sdpm.schema.lint import lint as lint_slides presentation = json.loads((tmpdir / "presentation.json").read_text(encoding="utf-8")) slide_set = set(measure_slides) lint_diag = [d for d in lint_slides(presentation) if d.get("slide") + 1 in slide_set] if lint_diag: result["errors"] = {"lintDiagnostics": lint_diag} - except Exception as e: + except Exception as e: logger.warning("Lint failed: %s", e) - # Layout bias (filter to measured slides; bias uses 1-based) - try: + # Layout bias (filter to measured slides; bias uses 1-based) + try: from sdpm.preview import check_layout_imbalance_data layout_bias = [b for b in check_layout_imbalance_data(pptx_path, slide_defs=slides) if b.get("slide") in slide_set] if layout_bias: result["warnings"] = {"layoutBias": layout_bias} - except Exception as e: + except Exception as e: logger.warning("Layout bias check failed: %s", e) if save: diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 3eae85d..6629bee 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -52,6 +52,7 @@ interface DefsData { interface AnimatedSlidePreviewProps { defsUrl: string composeUrl: string + slideId?: string onComplete?: () => void /** Fallback to render when compose version mismatches or fetch fails. */ fallback?: React.ReactNode @@ -81,7 +82,7 @@ function sanitizeSvg(raw: string): string { // --- Component --- -export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback }: AnimatedSlidePreviewProps) { +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onComplete, fallback }: AnimatedSlidePreviewProps) { const containerRef = useRef(null) const prevCompRef = useRef | null>(null) const intervalsRef = useRef([]) @@ -294,7 +295,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, onComplete, fallback if (error && fallback) return <>{fallback} return ( -
+
) diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 5b810e0..9431e23 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -53,6 +53,25 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo const { viewMode, setViewMode } = usePreferences() const containerRef = useRef(null) + /* ── Compose update detection → auto-scroll to changed slide ── */ + const prevComposeKeys = useRef>(new Map()) + + useEffect(() => { + let firstChanged: string | null = null + for (const slide of slides) { + const key = slide.composeUrl?.split("?")[0] || "" + const prev = prevComposeKeys.current.get(slide.slideId) || "" + if (prev && key && key !== prev && !firstChanged) { + firstChanged = slide.slideId + } + if (key) prevComposeKeys.current.set(slide.slideId, key) + } + if (firstChanged && containerRef.current) { + const el = containerRef.current.querySelector(`[data-slide-id="${firstChanged}"]`) + el?.scrollIntoView({ behavior: "smooth", block: "center" }) + } + }, [slides]) + /* ── Slide update detection for glow highlight ── */ const prevUrlKeys = useRef>(new Map()) const [updatedIds, setUpdatedIds] = useState>(new Set()) @@ -301,6 +320,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo key={slide.slideId} defsUrl={defsUrl} composeUrl={slide.composeUrl} + slideId={slide.slideId} fallback={ Date: Mon, 13 Apr 2026 15:03:30 +0900 Subject: [PATCH 05/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20subprocess=20import,=20=5Fexport=5Fsvg=20separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _export_svg() from _run_measure for reuse by compose - Compose generates SVG when measure_slides not present (save-only) - Add Dockerfile cache-bust for forced image rebuild SPEC: 20260413-0806_svg-composition-animation --- mcp-server/Dockerfile | 1 + mcp-server/server.py | 38 +++++++++++++++----------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 825af8e..bf5e48f 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -2,6 +2,7 @@ # Platform: ARM64 (required by AgentCore Runtime) # Listens on 0.0.0.0:8000/mcp (stateless streamable-http) # Includes LibreOffice + poppler for synchronous PNG/SVG generation +# cache-bust: 20260413-1457 FROM --platform=linux/arm64 public.ecr.aws/docker/library/fedora:41 diff --git a/mcp-server/server.py b/mcp-server/server.py index 6787d54..b6055d7 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -403,19 +403,23 @@ def _build_pptx(tmpdir: Path, slides: list[dict], build_kwargs: dict) -> Path: return pptx_path -def _run_measure(tmpdir: Path, pptx_path: Path, slide_numbers: list[int]) -> str: - """PPTX → SVG → bbox measurement → report string.""" +def _export_svg(tmpdir: Path, pptx_path: Path) -> Path: + """PPTX → SVG via LibreOffice. Returns svg_path.""" import subprocess - - from sdpm.preview.measure import measure_from_svg, format_measure_report - env = os.environ.copy() env["HOME"] = str(tmpdir) - subprocess.run( # nosec B603 + subprocess.run( ["soffice", "--headless", "--convert-to", "svg", "--outdir", str(tmpdir), str(pptx_path)], env=env, capture_output=True, text=True, timeout=120, check=True, ) - svg_path = tmpdir / "measure.svg" + return tmpdir / "measure.svg" + + +def _run_measure(tmpdir: Path, pptx_path: Path, slide_numbers: list[int]) -> str: + """PPTX → SVG → bbox measurement → report string.""" + from sdpm.preview.measure import measure_from_svg, format_measure_report + + svg_path = _export_svg(tmpdir, pptx_path) if not svg_path.exists(): return json.dumps({"error": "LibreOffice SVG export failed"}) @@ -697,24 +701,10 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, tmpdir, slides, build_kwargs = _prepare_workspace(deck_id, user_id, _storage) pptx_path = _build_pptx(tmpdir, slides, build_kwargs) - # SVG export (needed for both measure and compose) - svg_path = tmpdir / "measure.svg" - env = os.environ.copy() - env["HOME"] = str(tmpdir) - subprocess.run( - ["soffice", "--headless", "--convert-to", "svg", "--outdir", str(tmpdir), str(pptx_path)], - env=env, capture_output=True, text=True, timeout=120, check=True, - ) - - # Measure (only when requested) + # Measure (runs SVG export internally) if measure_slides: try: - from sdpm.preview.measure import measure_from_svg, format_measure_report - if svg_path.exists(): - results = measure_from_svg(svg_path=svg_path, slide_indices=measure_slides) - result["measure"] = format_measure_report(results) - else: - result["measure"] = json.dumps({"error": "LibreOffice SVG export failed"}) + result["measure"] = _run_measure(tmpdir, pptx_path, measure_slides) except Exception as e: result["measure"] = json.dumps({"error": str(e)}) @@ -746,6 +736,8 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, try: from tools.compose import extract_optimized_defs, split_slide_components, count_slides svg_path = tmpdir / "measure.svg" + if not svg_path.exists(): + _export_svg(tmpdir, pptx_path) if svg_path.exists(): import json as _json import time as _time From f98d79b76c36391a3bf6592ff763db3b35f4c26d Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 15:12:32 +0900 Subject: [PATCH 06/23] =?UTF-8?q?=E2=9C=A8=20feat(svg-composition-animatio?= =?UTF-8?q?n):=20initial=20compose=20animate,=20scroll=20position=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add initialLoad prop: page-load compose = instant, session-first = animate all - Scroll to top of changed slide with 24px padding instead of center SPEC: 20260413-0806_svg-composition-animation --- .../src/components/deck/AnimatedSlidePreview.tsx | 7 ++++++- web-ui/src/components/deck/SlideCarousel.tsx | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 6629bee..cc8d9fc 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -53,6 +53,8 @@ interface AnimatedSlidePreviewProps { defsUrl: string composeUrl: string slideId?: string + /** When true, skip animation on first render (page already had compose data). */ + initialLoad?: boolean onComplete?: () => void /** Fallback to render when compose version mismatches or fetch fails. */ fallback?: React.ReactNode @@ -82,7 +84,7 @@ function sanitizeSvg(raw: string): string { // --- Component --- -export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onComplete, fallback }: AnimatedSlidePreviewProps) { +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad, onComplete, fallback }: AnimatedSlidePreviewProps) { const containerRef = useRef(null) const prevCompRef = useRef | null>(null) const intervalsRef = useRef([]) @@ -144,6 +146,9 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onComplete, animTargets.add(i) } }) + } else if (!initialLoad) { + // First compose arrived during session → animate all + data.components.forEach((_, i) => animTargets.add(i)) } // Save for next diff diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 9431e23..6d1380d 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -53,6 +53,11 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo const { viewMode, setViewMode } = usePreferences() const containerRef = useRef(null) + /* ── Track which slides had composeUrl on initial render ── */ + const initialComposeIds = useRef>(new Set( + slides.filter(s => s.composeUrl).map(s => s.slideId) + )) + /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) @@ -68,7 +73,14 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo } if (firstChanged && containerRef.current) { const el = containerRef.current.querySelector(`[data-slide-id="${firstChanged}"]`) - el?.scrollIntoView({ behavior: "smooth", block: "center" }) + if (el) { + // Scroll so the full slide is visible with some top padding + const container = containerRef.current + const elRect = el.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const offset = elRect.top - containerRect.top + container.scrollTop - 24 + container.scrollTo({ top: offset, behavior: "smooth" }) + } } }, [slides]) @@ -321,6 +333,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo defsUrl={defsUrl} composeUrl={slide.composeUrl} slideId={slide.slideId} + initialLoad={initialComposeIds.current.has(slide.slideId)} fallback={ Date: Mon, 13 Apr 2026 15:22:00 +0900 Subject: [PATCH 07/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20scroll=20to=20actually=20changed=20slide=20via=20onAnim?= =?UTF-8?q?ate=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- .../components/deck/AnimatedSlidePreview.tsx | 6 +++++- web-ui/src/components/deck/SlideCarousel.tsx | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index cc8d9fc..88e1102 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -55,6 +55,8 @@ interface AnimatedSlidePreviewProps { slideId?: string /** When true, skip animation on first render (page already had compose data). */ initialLoad?: boolean + /** Called when this slide actually has components to animate. */ + onAnimate?: () => void onComplete?: () => void /** Fallback to render when compose version mismatches or fetch fails. */ fallback?: React.ReactNode @@ -84,7 +86,7 @@ function sanitizeSvg(raw: string): string { // --- Component --- -export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad, onComplete, fallback }: AnimatedSlidePreviewProps) { +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { const containerRef = useRef(null) const prevCompRef = useRef | null>(null) const intervalsRef = useRef([]) @@ -156,6 +158,8 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad data.components.forEach(c => newMap.set(makeKey(c), c.svg)) prevCompRef.current = newMap + if (animTargets.size > 0) onAnimate?.() + // --- Build SVG --- const vb = data.viewBox.split(" ").map(Number) container.innerHTML = "" diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 6d1380d..98dcc0f 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -60,21 +60,25 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) + const scrollTargetRef = useRef(null) useEffect(() => { - let firstChanged: string | null = null + let anyChanged = false for (const slide of slides) { const key = slide.composeUrl?.split("?")[0] || "" const prev = prevComposeKeys.current.get(slide.slideId) || "" - if (prev && key && key !== prev && !firstChanged) { - firstChanged = slide.slideId - } + if (prev && key && key !== prev) anyChanged = true if (key) prevComposeKeys.current.set(slide.slideId, key) } - if (firstChanged && containerRef.current) { - const el = containerRef.current.querySelector(`[data-slide-id="${firstChanged}"]`) + if (anyChanged) scrollTargetRef.current = null // reset, let onAnimate decide + }, [slides]) + + const handleAnimate = useCallback((slideId: string) => { + // Scroll to the first slide that actually animates + if (scrollTargetRef.current !== undefined && scrollTargetRef.current === null && containerRef.current) { + scrollTargetRef.current = slideId + const el = containerRef.current.querySelector(`[data-slide-id="${slideId}"]`) if (el) { - // Scroll so the full slide is visible with some top padding const container = containerRef.current const elRect = el.getBoundingClientRect() const containerRect = container.getBoundingClientRect() @@ -82,7 +86,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo container.scrollTo({ top: offset, behavior: "smooth" }) } } - }, [slides]) + }, []) /* ── Slide update detection for glow highlight ── */ const prevUrlKeys = useRef>(new Map()) @@ -334,6 +338,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo composeUrl={slide.composeUrl} slideId={slide.slideId} initialLoad={initialComposeIds.current.has(slide.slideId)} + onAnimate={() => handleAnimate(slide.slideId)} fallback={ Date: Mon, 13 Apr 2026 15:26:24 +0900 Subject: [PATCH 08/23] =?UTF-8?q?=F0=9F=8E=AF=20perf(svg-composition-anima?= =?UTF-8?q?tion):=20slower=20stagger/wireframe,=20keep=20typewriter=20fast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- web-ui/src/components/deck/AnimatedSlidePreview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 88e1102..a981a89 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -15,8 +15,8 @@ import DOMPurify from "dompurify" // --- Constants --- const COMPOSE_VERSION = 1 -const STAGGER_MS = 180 -const WIREFRAME_LEAD_MS = 280 +const STAGGER_MS = 260 +const WIREFRAME_LEAD_MS = 400 const TYPE_DURATION_MS = 800 const MIN_CHAR_MS = 15 const MAX_CHAR_MS = 50 From 2a802c8658d1054387c258d670d33c38568f697b Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 15:30:11 +0900 Subject: [PATCH 09/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20arm=20scroll=20only=20after=20compose=20URL=20change=20?= =?UTF-8?q?detected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- web-ui/src/components/deck/SlideCarousel.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 98dcc0f..40d79cd 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -60,7 +60,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) - const scrollTargetRef = useRef(null) + const scrollTargetRef = useRef(undefined) useEffect(() => { let anyChanged = false @@ -70,12 +70,11 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo if (prev && key && key !== prev) anyChanged = true if (key) prevComposeKeys.current.set(slide.slideId, key) } - if (anyChanged) scrollTargetRef.current = null // reset, let onAnimate decide + if (anyChanged) scrollTargetRef.current = null // arm scroll for next onAnimate }, [slides]) const handleAnimate = useCallback((slideId: string) => { - // Scroll to the first slide that actually animates - if (scrollTargetRef.current !== undefined && scrollTargetRef.current === null && containerRef.current) { + if (scrollTargetRef.current === null && containerRef.current) { scrollTargetRef.current = slideId const el = containerRef.current.querySelector(`[data-slide-id="${slideId}"]`) if (el) { From 8ef250de9bd4073ab1ecb72c295d0c2174fa1c4f Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 15:34:24 +0900 Subject: [PATCH 10/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20diff=20by=20text+class=20fingerprint,=20skip=20by=20com?= =?UTF-8?q?poseUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Diff uses text+class instead of raw SVG (stable across LibreOffice re-renders) - Skip re-render when composeUrl base path unchanged (ignore defs-only changes) SPEC: 20260413-0806_svg-composition-animation --- .../components/deck/AnimatedSlidePreview.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index a981a89..85dac06 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -64,6 +64,10 @@ interface AnimatedSlidePreviewProps { // --- Helpers --- +function makeFingerprint(c: ComposeComponent): string { + return `${c.class}|${c.text}` +} + function makeKey(c: ComposeComponent): string { return c.bbox ? `${c.class}|${c.bbox.x},${c.bbox.y},${c.bbox.w},${c.bbox.h}` @@ -105,7 +109,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad useEffect(() => () => cleanup(), [cleanup]) - const lastJsonRef = useRef("") + const lastComposeUrlRef = useRef("") useEffect(() => { let cancelled = false @@ -115,14 +119,14 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } - const defsText = await defsResp.text() - const compText = await compResp.text() - const combined = defsText + compText + const compUrlBase = composeUrl.split("?")[0] - // Skip if content unchanged - if (combined === lastJsonRef.current) return - lastJsonRef.current = combined + // Skip if same compose URL (defs change alone doesn't need re-render) + if (compUrlBase === lastComposeUrlRef.current) return + lastComposeUrlRef.current = compUrlBase + const defsText = await defsResp.text() + const compText = await compResp.text() const defsData: DefsData = JSON.parse(defsText) const data: ComposeData = JSON.parse(compText) @@ -143,8 +147,8 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad if (prevMap) { data.components.forEach((comp, i) => { const key = makeKey(comp) - const prevSvg = prevMap.get(key) - if (prevSvg === undefined || prevSvg !== comp.svg) { + const prevFp = prevMap.get(key) + if (prevFp === undefined || prevFp !== makeFingerprint(comp)) { animTargets.add(i) } }) @@ -155,7 +159,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad // Save for next diff const newMap = new Map() - data.components.forEach(c => newMap.set(makeKey(c), c.svg)) + data.components.forEach(c => newMap.set(makeKey(c), makeFingerprint(c))) prevCompRef.current = newMap if (animTargets.size > 0) onAnimate?.() From c5fac7bb36965abe81dbae9ed2053f6403ff149a Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 15:45:38 +0900 Subject: [PATCH 11/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20compose=20key=20prefix=20collision=20(slide=5F1=20match?= =?UTF-8?q?ed=20slide=5F10/11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- api/index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/index.py b/api/index.py index 61ec4d0..26c9649 100644 --- a/api/index.py +++ b/api/index.py @@ -445,7 +445,7 @@ def _latest_compose_key(prefix: str, keys: set) -> Optional[str]: best_epoch, best_key = epoch, k return best_key - defs_key = _latest_compose_key(f"decks/{deck_id}/compose/defs", compose_keys) + defs_key = _latest_compose_key(f"decks/{deck_id}/compose/defs_", compose_keys) has_defs = defs_key is not None for i, s in enumerate(presentation.get("slides", [])): @@ -453,7 +453,7 @@ def _latest_compose_key(prefix: str, keys: set) -> Optional[str]: slide_preview = _resolve_preview_url(deck_id, sid, preview_keys) slide_entry: Dict[str, Any] = {"slideId": sid, "previewUrl": slide_preview} # Compose URL for animation (epoch-keyed) - compose_key = _latest_compose_key(f"decks/{deck_id}/compose/slide_{i + 1}", compose_keys) + compose_key = _latest_compose_key(f"decks/{deck_id}/compose/slide_{i + 1}_", compose_keys) if compose_key: slide_entry["composeUrl"] = preview_url(compose_key) if include_json: From c791e2a89d90ee6a7150858b9e7557f47e49cdb0 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 15:54:50 +0900 Subject: [PATCH 12/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20cancel=20stale=20polling=20on=20deck=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- web-ui/src/hooks/useWorkspace.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index 022f485..6fb559d 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -97,13 +97,16 @@ export function useWorkspace( const INTERVALS = [1000, 2000, 4000, 6000] let step = 0 + let cancelled = false + async function poll() { - if (!idToken || !deckIdToLoad || deckIdToLoad === "__polling__") { - scheduleNext() + if (cancelled || !idToken || !deckIdToLoad || deckIdToLoad === "__polling__") { + if (!cancelled) scheduleNext() return } try { const data = await getDeck(deckIdToLoad, idToken) + if (cancelled) return // Detect slide changes (added/removed/preview updated) const slideKey = data.slides.map((s) => { const base = s.previewUrl?.split("?")[0] || "" @@ -157,7 +160,7 @@ export function useWorkspace( } catch { // Deck may not exist yet } - scheduleNext() + if (!cancelled) scheduleNext() } function scheduleNext() { @@ -173,6 +176,7 @@ export function useWorkspace( } return () => { + cancelled = true if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current) } }, [isAuthenticated, idToken, activeDeckId, createdDeckId]) From 27b45b30ad54f62af858bb682e2cfeffb4611419 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 16:16:35 +0900 Subject: [PATCH 13/23] =?UTF-8?q?=E2=9C=A8=20feat(svg-composition-animatio?= =?UTF-8?q?n):=20backend=20diff=20with=20sourceHash=20+=20compose=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.py: sourceHash (slide JSON md5) for cross-slide matching - server.py: 2-level diff (slide-level sourceHash + component-level class+bbox) - server.py: cleanup old epoch compose files after upload - AnimatedSlidePreview: simplified to use backend changed flag only - SlideCarousel: removed initialComposeIds, kept scroll via onAnimate SPEC: 20260413-0806_svg-composition-animation --- mcp-server/server.py | 61 ++++++- .../components/deck/AnimatedSlidePreview.tsx | 167 ++++++------------ web-ui/src/components/deck/SlideCarousel.tsx | 8 +- 3 files changed, 108 insertions(+), 128 deletions(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index b6055d7..7067de5 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -732,9 +732,9 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, if save: # Compose: SVG → optimized JSON for WebUI animation # Runs synchronously — measured at ~300ms for 8 slides (≪3s threshold) - # Intentionally re-extracts defs each time (60KB PUT, negligible cost) try: from tools.compose import extract_optimized_defs, split_slide_components, count_slides + import hashlib as _hashlib svg_path = tmpdir / "measure.svg" if not svg_path.exists(): _export_svg(tmpdir, pptx_path) @@ -742,26 +742,77 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, import json as _json import time as _time _epoch = int(_time.time()) + compose_prefix = f"decks/{deck_id}/compose/" + + # 1. List old compose keys (for cleanup + prev data) + old_keys = _storage.list_files(prefix=compose_prefix, bucket=_storage.pptx_bucket) + + # 2. Load previous slide compose data keyed by sourceHash + prev_by_hash: dict[str, list[dict]] = {} + for k in old_keys: + if "/slide_" not in k: + continue + try: + raw = _storage.download_file_from_pptx_bucket(k) + prev_data = _json.loads(raw) + h = prev_data.get("sourceHash") + if h and "components" in prev_data: + prev_by_hash[h] = prev_data["components"] + except Exception: + pass + + # 3. Upload defs defs_data = extract_optimized_defs(svg_path) _storage.upload_file( - key=f"decks/{deck_id}/compose/defs_{_epoch}.json", + key=f"{compose_prefix}defs_{_epoch}.json", data=_json.dumps(defs_data, ensure_ascii=False).encode(), content_type="application/json", ) - # Generate compose for all slides (diff handled by frontend) + + # 4. Generate compose for all slides with changed flags _total = count_slides(svg_path) for sn in range(1, _total + 1): try: comp_data = split_slide_components(svg_path, sn) + # sourceHash from slide JSON + src_hash = "" + if sn <= len(slides): + src_hash = _hashlib.md5(_json.dumps(slides[sn - 1], sort_keys=True, ensure_ascii=False).encode()).hexdigest() + comp_data["sourceHash"] = src_hash + + # Diff: find prev slide by sourceHash + prev_comps = prev_by_hash.get(src_hash) + if prev_comps is not None: + # Component-level diff + def _mk(c: dict) -> str: + b = c.get("bbox") + return f"{c['class']}|{b['x']},{b['y']},{b['w']},{b['h']}" if b else f"{c['class']}|none" + def _fp(c: dict) -> str: + return f"{c['class']}|{c.get('text', '')}" + prev_map = {_mk(c): _fp(c) for c in prev_comps} + for c in comp_data["components"]: + k = _mk(c) + c["changed"] = k not in prev_map or prev_map[k] != _fp(c) + else: + for c in comp_data["components"]: + c["changed"] = True + _storage.upload_file( - key=f"decks/{deck_id}/compose/slide_{sn}_{_epoch}.json", + key=f"{compose_prefix}slide_{sn}_{_epoch}.json", data=_json.dumps(comp_data, ensure_ascii=False).encode(), content_type="application/json", ) except Exception: logger.error("compose failed for slide %d", sn, exc_info=True) + + # 5. Cleanup old compose files + for k in old_keys: + try: + _storage._s3.delete_object(Bucket=_storage.pptx_bucket, Key=k) + except Exception: + pass except Exception: - logger.error("compose defs failed", exc_info=True) + logger.error("compose failed", exc_info=True) # tmpdir cleanup (WebP generation only in generate_pptx) shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 85dac06..d79b1b4 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -4,14 +4,12 @@ * AnimatedSlidePreview — Builds SVG from compose JSON and animates * changed components with agent cursors, wireframes, and typewriter. * - * Diff-based: only changed/new components animate; unchanged show instantly. - * Key: class+bbox for stable identity across insertions/deletions. + * Backend provides `changed: boolean` per component — no frontend diff needed. */ "use client" import { useEffect, useRef, useState, useCallback } from "react" -import DOMPurify from "dompurify" // --- Constants --- const COMPOSE_VERSION = 1 @@ -34,6 +32,7 @@ interface ComposeComponent { bbox: { x: number; y: number; w: number; h: number } | null text: string svg: string + changed: boolean } interface ComposeData { @@ -53,82 +52,53 @@ interface AnimatedSlidePreviewProps { defsUrl: string composeUrl: string slideId?: string - /** When true, skip animation on first render (page already had compose data). */ - initialLoad?: boolean - /** Called when this slide actually has components to animate. */ onAnimate?: () => void onComplete?: () => void - /** Fallback to render when compose version mismatches or fetch fails. */ fallback?: React.ReactNode } -// --- Helpers --- - -function makeFingerprint(c: ComposeComponent): string { - return `${c.class}|${c.text}` -} - -function makeKey(c: ComposeComponent): string { - return c.bbox - ? `${c.class}|${c.bbox.x},${c.bbox.y},${c.bbox.w},${c.bbox.h}` - : `${c.class}|none` -} - function assignAgent(comp: ComposeComponent) { const cls = comp.class || "" if (cls === "TitleText" || cls === "SubtitleText") return AGENTS[0] if (comp.text.length > 20) return AGENTS[1] if (cls === "Graphic" || cls.includes("image")) return AGENTS[2] if (cls.includes("ConnectorShape") || cls.includes("line")) return AGENTS[3] - return AGENTS[4] // Decorator — deterministic, no random + return AGENTS[4] } -function sanitizeSvg(raw: string): string { - // TODO: Re-enable DOMPurify with correct SVG config after visual verification - return raw -} - -// --- Component --- - -export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { const containerRef = useRef(null) - const prevCompRef = useRef | null>(null) - const intervalsRef = useRef([]) const timersRef = useRef[]>([]) + const intervalsRef = useRef([]) + const lastComposeUrlRef = useRef("") const [error, setError] = useState(false) const reducedMotion = useRef( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) const cleanup = useCallback(() => { - intervalsRef.current.forEach(clearInterval) - intervalsRef.current = [] timersRef.current.forEach(clearTimeout) timersRef.current = [] + intervalsRef.current.forEach(clearInterval) + intervalsRef.current = [] }, []) useEffect(() => () => cleanup(), [cleanup]) - const lastComposeUrlRef = useRef("") - useEffect(() => { let cancelled = false async function run() { try { - const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) - if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } - const compUrlBase = composeUrl.split("?")[0] - - // Skip if same compose URL (defs change alone doesn't need re-render) if (compUrlBase === lastComposeUrlRef.current) return lastComposeUrlRef.current = compUrlBase - const defsText = await defsResp.text() - const compText = await compResp.text() - const defsData: DefsData = JSON.parse(defsText) - const data: ComposeData = JSON.parse(compText) + const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) + if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } + + const defsData: DefsData = await defsResp.json() + const data: ComposeData = await compResp.json() if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { setError(true); return @@ -139,35 +109,17 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad cleanup() - // --- Diff detection --- - // First load: show everything instantly (no animation). - // Subsequent updates: animate only changed/new components. + // Animation targets from backend `changed` flag const animTargets = new Set() - const prevMap = prevCompRef.current - if (prevMap) { - data.components.forEach((comp, i) => { - const key = makeKey(comp) - const prevFp = prevMap.get(key) - if (prevFp === undefined || prevFp !== makeFingerprint(comp)) { - animTargets.add(i) - } - }) - } else if (!initialLoad) { - // First compose arrived during session → animate all - data.components.forEach((_, i) => animTargets.add(i)) - } - - // Save for next diff - const newMap = new Map() - data.components.forEach(c => newMap.set(makeKey(c), makeFingerprint(c))) - prevCompRef.current = newMap + data.components.forEach((comp, i) => { + if (comp.changed) animTargets.add(i) + }) if (animTargets.size > 0) onAnimate?.() // --- Build SVG --- const vb = data.viewBox.split(" ").map(Number) container.innerHTML = "" - // Remove old overlays container.parentElement?.querySelectorAll(".asp-overlay").forEach(el => el.remove()) const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg") @@ -179,7 +131,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad // Background if (data.bgSvg) { const g = document.createElementNS("http://www.w3.org/2000/svg", "g") - g.innerHTML = sanitizeSvg(data.bgSvg) + g.innerHTML = data.bgSvg svgEl.appendChild(g) } else { const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect") @@ -191,26 +143,21 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad // Defs const defsG = document.createElementNS("http://www.w3.org/2000/svg", "g") - defsG.innerHTML = sanitizeSvg(defsData.defs) + defsG.innerHTML = defsData.defs while (defsG.firstChild) svgEl.appendChild(defsG.firstChild) // Components data.components.forEach((comp, i) => { const g = document.createElementNS("http://www.w3.org/2000/svg", "g") - g.innerHTML = sanitizeSvg(comp.svg) + g.innerHTML = comp.svg g.dataset.index = String(i) - if (!animTargets.has(i) || reducedMotion.current) { - g.style.opacity = "1" - } else { - g.style.opacity = "0" - } + g.style.opacity = (animTargets.has(i) && !reducedMotion.current) ? "0" : "1" svgEl.appendChild(g) }) container.appendChild(svgEl) - // --- Reduced motion: done --- - if (reducedMotion.current) { + if (reducedMotion.current || animTargets.size === 0) { onComplete?.() return } @@ -231,7 +178,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad const pctW = (comp.bbox.w / vb[2]) * 100 const pctH = (comp.bbox.h / vb[3]) * 100 - // Phase 1: cursor fly-in const t1 = setTimeout(() => { if (cancelled) return const cursor = document.createElement("div") @@ -245,7 +191,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad cursor.style.top = `${pctT}%` }) - // Phase 2: wireframe const t2 = setTimeout(() => { if (cancelled) return const wf = document.createElement("div") @@ -253,13 +198,11 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad wf.style.cssText = `left:${pctL}%;top:${pctT}%;width:${pctW}%;height:${pctH}%;border:1px solid ${agent.color};border-radius:2px;box-shadow:inset 0 0 16px ${agent.glow};opacity:1;clip-path:inset(0 100% 100% 0);animation:asp-wf-drag 0.35s cubic-bezier(0.16,1,0.3,1) forwards;` overlayContainer.appendChild(wf) - // Move cursor to bottom-right const endL = ((comp.bbox!.x + comp.bbox!.w) / vb[2]) * 100 const endT = ((comp.bbox!.y + comp.bbox!.h) / vb[3]) * 100 cursor.style.left = `${endL}%` cursor.style.top = `${endT}%` - // Phase 3: materialize const t3 = setTimeout(() => { if (cancelled) return const g = svgEl.querySelector(`g[data-index="${i}"]`) as SVGGElement | null @@ -270,7 +213,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad requestAnimationFrame(() => { g.style.filter = "brightness(1) saturate(1)" }) typewrite(g) } - // Fade out wireframe + cursor const t4 = setTimeout(() => { wf.style.transition = "opacity 0.4s ease-out" wf.style.opacity = "0" @@ -286,7 +228,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad timersRef.current.push(t1) }) - // onComplete after all animations const totalTime = staggerIdx * STAGGER_MS + WIREFRAME_LEAD_MS + 1000 const tDone = setTimeout(() => { if (!cancelled) { @@ -295,7 +236,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad } }, totalTime) timersRef.current.push(tDone) - } catch { if (!cancelled) setError(true) } @@ -303,7 +243,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad run() return () => { cancelled = true } - }, [defsUrl, composeUrl, cleanup, onComplete]) + }, [defsUrl, composeUrl, cleanup, onComplete, onAnimate]) if (error && fallback) return <>{fallback} @@ -314,41 +254,36 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, initialLoad ) } -// --- Typewriter --- - function typewrite(compEl: SVGGElement) { - const textEls = compEl.querySelectorAll(".SVGTextShape") - textEls.forEach(textEl => { - const tspans = textEl.querySelectorAll("tspan") - const leafSpans: { el: Element; fullText: string; saved: Record }[] = [] - let totalChars = 0 - tspans.forEach(ts => { - if (ts.querySelectorAll("tspan").length === 0 && ts.textContent) { - const saved: Record = {} - for (const attr of ["textLength", "lengthAdjust"]) { - if (ts.hasAttribute(attr)) { - saved[attr] = ts.getAttribute(attr)! - ts.removeAttribute(attr) - } + const tspans = compEl.querySelectorAll("tspan") + const leafSpans: { el: Element; fullText: string; saved: Record }[] = [] + let totalChars = 0 + tspans.forEach(ts => { + if (ts.querySelectorAll("tspan").length === 0 && ts.textContent) { + const saved: Record = {} + for (const attr of ["textLength", "lengthAdjust"]) { + if (ts.hasAttribute(attr)) { + saved[attr] = ts.getAttribute(attr)! + ts.removeAttribute(attr) } - totalChars += ts.textContent.length - leafSpans.push({ el: ts, fullText: ts.textContent, saved }) - ts.textContent = "" - } - }) - if (!leafSpans.length) return - const charMs = Math.max(MIN_CHAR_MS, Math.min(MAX_CHAR_MS, Math.floor(TYPE_DURATION_MS / totalChars))) - let spanIdx = 0, charIdx = 0 - const iv = window.setInterval(() => { - if (spanIdx >= leafSpans.length) { clearInterval(iv); return } - const span = leafSpans[spanIdx] - charIdx++ - span.el.textContent = span.fullText.slice(0, charIdx) - if (charIdx >= span.fullText.length) { - for (const [a, v] of Object.entries(span.saved)) span.el.setAttribute(a, v) - spanIdx++ - charIdx = 0 } - }, charMs) + totalChars += ts.textContent.length + leafSpans.push({ el: ts, fullText: ts.textContent, saved }) + ts.textContent = "" + } }) + if (!leafSpans.length) return + const charMs = Math.max(MIN_CHAR_MS, Math.min(MAX_CHAR_MS, Math.floor(TYPE_DURATION_MS / totalChars))) + let spanIdx = 0, charIdx = 0 + const iv = window.setInterval(() => { + if (spanIdx >= leafSpans.length) { clearInterval(iv); return } + const span = leafSpans[spanIdx] + charIdx++ + span.el.textContent = span.fullText.slice(0, charIdx) + if (charIdx >= span.fullText.length) { + for (const [a, v] of Object.entries(span.saved)) span.el.setAttribute(a, v) + spanIdx++ + charIdx = 0 + } + }, charMs) } diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 40d79cd..349da0c 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -9,7 +9,7 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { SlidePreview, getDeckWithJson } from "@/services/deckService" import type { SpecFiles } from "@/services/deckService" import { Download, FileJson, Layers, Loader2, LayoutGrid, Rows3 } from "lucide-react" @@ -53,11 +53,6 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo const { viewMode, setViewMode } = usePreferences() const containerRef = useRef(null) - /* ── Track which slides had composeUrl on initial render ── */ - const initialComposeIds = useRef>(new Set( - slides.filter(s => s.composeUrl).map(s => s.slideId) - )) - /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) const scrollTargetRef = useRef(undefined) @@ -336,7 +331,6 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo defsUrl={defsUrl} composeUrl={slide.composeUrl} slideId={slide.slideId} - initialLoad={initialComposeIds.current.has(slide.slideId)} onAnimate={() => handleAnimate(slide.slideId)} fallback={ Date: Mon, 13 Apr 2026 22:29:20 +0900 Subject: [PATCH 14/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20slot=20fallback=20diff=20+=20defer=20during=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.py: fallback to slot-number diff when sourceHash mismatches - AnimatedSlidePreview: first render = instant, defer composeUrl changes during animation SPEC: 20260413-0806_svg-composition-animation --- mcp-server/server.py | 22 +++++++++++--- .../components/deck/AnimatedSlidePreview.tsx | 29 +++++++++++++++---- web-ui/src/hooks/useWorkspace.ts | 2 ++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index 7067de5..dbaf63d 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -747,19 +747,31 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, # 1. List old compose keys (for cleanup + prev data) old_keys = _storage.list_files(prefix=compose_prefix, bucket=_storage.pptx_bucket) - # 2. Load previous slide compose data keyed by sourceHash + # 2. Load previous slide compose data keyed by sourceHash + slot number prev_by_hash: dict[str, list[dict]] = {} + prev_by_slot: list[list[dict]] = [] # ordered by slide number + prev_slot_map: dict[int, list[dict]] = {} for k in old_keys: if "/slide_" not in k: continue try: raw = _storage.download_file_from_pptx_bucket(k) prev_data = _json.loads(raw) + comps = prev_data.get("components", []) h = prev_data.get("sourceHash") - if h and "components" in prev_data: - prev_by_hash[h] = prev_data["components"] + if h: + prev_by_hash[h] = comps + # Extract slot number from key: slide_{N}_{epoch}.json + import re as _re + m = _re.search(r"/slide_(\d+)_", k) + if m: + prev_slot_map[int(m.group(1))] = comps except Exception: pass + # Build ordered list + if prev_slot_map: + max_slot = max(prev_slot_map.keys()) + prev_by_slot = [prev_slot_map.get(i, []) for i in range(1, max_slot + 1)] # 3. Upload defs defs_data = extract_optimized_defs(svg_path) @@ -780,8 +792,10 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, src_hash = _hashlib.md5(_json.dumps(slides[sn - 1], sort_keys=True, ensure_ascii=False).encode()).hexdigest() comp_data["sourceHash"] = src_hash - # Diff: find prev slide by sourceHash + # Diff: find prev slide by sourceHash, fallback to same slot number prev_comps = prev_by_hash.get(src_hash) + if prev_comps is None and sn <= len(prev_by_slot): + prev_comps = prev_by_slot[sn - 1] if prev_comps is not None: # Component-level diff def _mk(c: dict) -> str: diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index d79b1b4..86c7c18 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -5,6 +5,7 @@ * changed components with agent cursors, wireframes, and typewriter. * * Backend provides `changed: boolean` per component — no frontend diff needed. + * First render = instant (page load). Subsequent composeUrl changes = animate changed. */ "use client" @@ -71,6 +72,9 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, const timersRef = useRef[]>([]) const intervalsRef = useRef([]) const lastComposeUrlRef = useRef("") + const hasRenderedRef = useRef(false) + const animatingRef = useRef(false) + const pendingUrlRef = useRef(null) const [error, setError] = useState(false) const reducedMotion = useRef( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches @@ -92,6 +96,13 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, try { const compUrlBase = composeUrl.split("?")[0] if (compUrlBase === lastComposeUrlRef.current) return + // Defer if animating — will be picked up after animation completes + if (animatingRef.current) { + pendingUrlRef.current = composeUrl + return + } + const isFirstRender = !hasRenderedRef.current + hasRenderedRef.current = true lastComposeUrlRef.current = compUrlBase const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) @@ -109,13 +120,20 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, cleanup() - // Animation targets from backend `changed` flag + // First render = instant (page load). Subsequent = use backend changed flag. const animTargets = new Set() - data.components.forEach((comp, i) => { - if (comp.changed) animTargets.add(i) - }) + if (!isFirstRender) { + data.components.forEach((comp, i) => { + if (comp.changed) animTargets.add(i) + }) + } + + console.log(`[ASP] ${slideId}: ${animTargets.size}/${data.components.length} changed, first=${isFirstRender}, url=${compUrlBase.split('/').pop()}`) - if (animTargets.size > 0) onAnimate?.() + if (animTargets.size > 0) { + onAnimate?.() + animatingRef.current = true + } // --- Build SVG --- const vb = data.viewBox.split(" ").map(Number) @@ -232,6 +250,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, const tDone = setTimeout(() => { if (!cancelled) { overlayContainer.remove() + animatingRef.current = false onComplete?.() } }, totalTime) diff --git a/web-ui/src/hooks/useWorkspace.ts b/web-ui/src/hooks/useWorkspace.ts index 6fb559d..5247086 100644 --- a/web-ui/src/hooks/useWorkspace.ts +++ b/web-ui/src/hooks/useWorkspace.ts @@ -107,6 +107,7 @@ export function useWorkspace( try { const data = await getDeck(deckIdToLoad, idToken) if (cancelled) return + console.log(`[poll] slides=${data.slides.length}, compose=${data.slides.filter((s: any) => s.composeUrl).length}, defsUrl=${!!data.defsUrl}, epoch=${data.slides[0]?.composeUrl?.split('?')[0]?.match(/\d{10}/)?.[0] || 'none'}`) // Detect slide changes (added/removed/preview updated) const slideKey = data.slides.map((s) => { const base = s.previewUrl?.split("?")[0] || "" @@ -139,6 +140,7 @@ export function useWorkspace( const base = s.composeUrl.split("?")[0] const cachedBase = cached?.url.split("?")[0] || "" if (base !== cachedBase) { + console.log(`[useWorkspace] composeUrl changed: ${s.slideId}`, cachedBase.split('/').pop(), '→', base.split('/').pop()) stablePreviewUrls.current.set(cacheKey, { url: s.composeUrl }) } else if (cached) { s.composeUrl = cached.url From 06932b66638767db62434fdfbd857c9f8887912d Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 22:48:46 +0900 Subject: [PATCH 15/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20skipAnimation=20prop=20+=20new=20slide=20detection=20+?= =?UTF-8?q?=20defer=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnimatedSlidePreview: interval-based check (no useEffect dep on composeUrl), skipAnimation prop for instant render on page load - SlideCarousel: deckReadyRef tracks initial load, detects new slides for scroll - Remove debug logs SPEC: 20260413-0806_svg-composition-animation --- .../components/deck/AnimatedSlidePreview.tsx | 86 ++++++++++--------- web-ui/src/components/deck/SlideCarousel.tsx | 9 +- web-ui/src/hooks/useWorkspace.ts | 2 - 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 86c7c18..6fb119b 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -53,6 +53,7 @@ interface AnimatedSlidePreviewProps { defsUrl: string composeUrl: string slideId?: string + skipAnimation?: boolean onAnimate?: () => void onComplete?: () => void fallback?: React.ReactNode @@ -67,14 +68,12 @@ function assignAgent(comp: ComposeComponent) { return AGENTS[4] } -export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { +export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimation, onAnimate, onComplete, fallback }: AnimatedSlidePreviewProps) { const containerRef = useRef(null) const timersRef = useRef[]>([]) const intervalsRef = useRef([]) const lastComposeUrlRef = useRef("") - const hasRenderedRef = useRef(false) const animatingRef = useRef(false) - const pendingUrlRef = useRef(null) const [error, setError] = useState(false) const reducedMotion = useRef( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches @@ -89,46 +88,46 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, useEffect(() => () => cleanup(), [cleanup]) + // Track latest props in refs so the interval can read them without re-triggering + const composeUrlRef = useRef(composeUrl) + const defsUrlRef = useRef(defsUrl) + composeUrlRef.current = composeUrl + defsUrlRef.current = defsUrl + useEffect(() => { let cancelled = false - async function run() { - try { - const compUrlBase = composeUrl.split("?")[0] - if (compUrlBase === lastComposeUrlRef.current) return - // Defer if animating — will be picked up after animation completes - if (animatingRef.current) { - pendingUrlRef.current = composeUrl - return - } - const isFirstRender = !hasRenderedRef.current - hasRenderedRef.current = true - lastComposeUrlRef.current = compUrlBase + function check() { + const compUrlBase = composeUrlRef.current.split("?")[0] + if (compUrlBase === lastComposeUrlRef.current) return + if (animatingRef.current) return // defer until animation completes + lastComposeUrlRef.current = compUrlBase - const [defsResp, compResp] = await Promise.all([fetch(defsUrl), fetch(composeUrl)]) - if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } + ;(async () => { + try { + const [defsResp, compResp] = await Promise.all([fetch(defsUrlRef.current), fetch(composeUrlRef.current)]) + if (cancelled || !defsResp.ok || !compResp.ok) { setError(true); return } - const defsData: DefsData = await defsResp.json() - const data: ComposeData = await compResp.json() + const defsData: DefsData = await defsResp.json() + const data: ComposeData = await compResp.json() - if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { - setError(true); return - } + if (defsData.version !== COMPOSE_VERSION || data.version !== COMPOSE_VERSION) { + setError(true); return + } - const container = containerRef.current - if (!container || cancelled) return + const container = containerRef.current + if (!container || cancelled) return - cleanup() + cleanup() - // First render = instant (page load). Subsequent = use backend changed flag. - const animTargets = new Set() - if (!isFirstRender) { - data.components.forEach((comp, i) => { - if (comp.changed) animTargets.add(i) - }) - } + // skipAnimation = instant. Otherwise use backend changed flag. + const animTargets = new Set() + if (!skipAnimation) { + data.components.forEach((comp, i) => { + if (comp.changed) animTargets.add(i) + }) + } - console.log(`[ASP] ${slideId}: ${animTargets.size}/${data.components.length} changed, first=${isFirstRender}, url=${compUrlBase.split('/').pop()}`) if (animTargets.size > 0) { onAnimate?.() @@ -248,21 +247,24 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, onAnimate, const totalTime = staggerIdx * STAGGER_MS + WIREFRAME_LEAD_MS + 1000 const tDone = setTimeout(() => { - if (!cancelled) { - overlayContainer.remove() - animatingRef.current = false - onComplete?.() - } + animatingRef.current = false + overlayContainer.remove() + onComplete?.() + // Check if a new composeUrl arrived during animation + check() }, totalTime) timersRef.current.push(tDone) } catch { - if (!cancelled) setError(true) + setError(true) } + })() } - run() - return () => { cancelled = true } - }, [defsUrl, composeUrl, cleanup, onComplete, onAnimate]) + check() + const iv = window.setInterval(check, 1000) + return () => { cancelled = true; clearInterval(iv); cleanup() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slideId]) if (error && fallback) return <>{fallback} diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 349da0c..169d82d 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -56,15 +56,21 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) const scrollTargetRef = useRef(undefined) + const deckReadyRef = useRef(false) useEffect(() => { let anyChanged = false for (const slide of slides) { const key = slide.composeUrl?.split("?")[0] || "" const prev = prevComposeKeys.current.get(slide.slideId) || "" - if (prev && key && key !== prev) anyChanged = true + if (key && prev && key !== prev) anyChanged = true + // New slide added after deck was ready + if (key && !prev && deckReadyRef.current) anyChanged = true if (key) prevComposeKeys.current.set(slide.slideId, key) } + if (!deckReadyRef.current && slides.some(s => s.composeUrl)) { + deckReadyRef.current = true + } if (anyChanged) scrollTargetRef.current = null // arm scroll for next onAnimate }, [slides]) @@ -331,6 +337,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo defsUrl={defsUrl} composeUrl={slide.composeUrl} slideId={slide.slideId} + skipAnimation={!deckReadyRef.current} onAnimate={() => handleAnimate(slide.slideId)} fallback={ s.composeUrl).length}, defsUrl=${!!data.defsUrl}, epoch=${data.slides[0]?.composeUrl?.split('?')[0]?.match(/\d{10}/)?.[0] || 'none'}`) // Detect slide changes (added/removed/preview updated) const slideKey = data.slides.map((s) => { const base = s.previewUrl?.split("?")[0] || "" @@ -140,7 +139,6 @@ export function useWorkspace( const base = s.composeUrl.split("?")[0] const cachedBase = cached?.url.split("?")[0] || "" if (base !== cachedBase) { - console.log(`[useWorkspace] composeUrl changed: ${s.slideId}`, cachedBase.split('/').pop(), '→', base.split('/').pop()) stablePreviewUrls.current.set(cacheKey, { url: s.composeUrl }) } else if (cached) { s.composeUrl = cached.url From 5d04d89d05a06f1de9a765d0f65f2ede2af40d57 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 22:55:04 +0900 Subject: [PATCH 16/23] =?UTF-8?q?=F0=9F=94=92=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20md5=20usedforsecurity=3DFalse=20for=20bandit=20B303?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC: 20260413-0806_svg-composition-animation --- mcp-server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index dbaf63d..d240d07 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -789,7 +789,7 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, # sourceHash from slide JSON src_hash = "" if sn <= len(slides): - src_hash = _hashlib.md5(_json.dumps(slides[sn - 1], sort_keys=True, ensure_ascii=False).encode()).hexdigest() + src_hash = _hashlib.md5(_json.dumps(slides[sn - 1], sort_keys=True, ensure_ascii=False).encode(), usedforsecurity=False).hexdigest() comp_data["sourceHash"] = src_hash # Diff: find prev slide by sourceHash, fallback to same slot number From 82683421e55e7a4c69d002ec21f5b7e342e519e2 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 22:59:52 +0900 Subject: [PATCH 17/23] =?UTF-8?q?=F0=9F=93=9A=20docs:=20add=20ASH=20securi?= =?UTF-8?q?ty=20scanning=20to=20tech.md,=20opt-in=20to=20git?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .kiro/steering/tech.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .kiro/steering/tech.md diff --git a/.gitignore b/.gitignore index cdbc9a9..81c1cf3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ web-ui/public/aws-exports.json !.kiro/steering/ .kiro/steering/* !.kiro/steering/principles.md +!.kiro/steering/tech.md !.kiro/steering/versioning.md !.kiro/steering/steering-policy.md diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md new file mode 100644 index 0000000..d7af307 --- /dev/null +++ b/.kiro/steering/tech.md @@ -0,0 +1,19 @@ + + +# Tech + +## AWS Environment +- Account: 510966039585 +- Region: ap-northeast-1 +- Credentials: `ada credentials update --account 510966039585 --role Admin --provider isengard --once` + +## Deployment +- WebUI: `AWS_DEFAULT_REGION=ap-northeast-1 bash scripts/deploy_webui.sh` +- CDK stacks: SdpmWebUi, SdpmAgent, SdpmRuntime, SdpmPngWorker, SdpmData, SdpmAuth + +## Security Scanning +- ASH (Automated Security Helper) v3 +- Local: `ash scan --mode local --fail-on-findings` +- Install: `alias ash="uvx git+https://github.com/awslabs/automated-security-helper.git@v3"` +- CI: GitHub Actions `.github/workflows/` で `--fail-on-findings` 付きで実行 +- md5等の非セキュリティ用途ハッシュには `usedforsecurity=False` を付与(bandit B303) From f99f3807807c2c19179f7dd7d32f7572164cc6cc Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 23:01:02 +0900 Subject: [PATCH 18/23] =?UTF-8?q?=F0=9F=A7=B9=20cleanup:=20remove=20tech.m?= =?UTF-8?q?d=20from=20git=20(contains=20internal=20info)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - .kiro/steering/tech.md | 19 ------------------- 2 files changed, 20 deletions(-) delete mode 100644 .kiro/steering/tech.md diff --git a/.gitignore b/.gitignore index 81c1cf3..cdbc9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ web-ui/public/aws-exports.json !.kiro/steering/ .kiro/steering/* !.kiro/steering/principles.md -!.kiro/steering/tech.md !.kiro/steering/versioning.md !.kiro/steering/steering-policy.md diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md deleted file mode 100644 index d7af307..0000000 --- a/.kiro/steering/tech.md +++ /dev/null @@ -1,19 +0,0 @@ - - -# Tech - -## AWS Environment -- Account: 510966039585 -- Region: ap-northeast-1 -- Credentials: `ada credentials update --account 510966039585 --role Admin --provider isengard --once` - -## Deployment -- WebUI: `AWS_DEFAULT_REGION=ap-northeast-1 bash scripts/deploy_webui.sh` -- CDK stacks: SdpmWebUi, SdpmAgent, SdpmRuntime, SdpmPngWorker, SdpmData, SdpmAuth - -## Security Scanning -- ASH (Automated Security Helper) v3 -- Local: `ash scan --mode local --fail-on-findings` -- Install: `alias ash="uvx git+https://github.com/awslabs/automated-security-helper.git@v3"` -- CI: GitHub Actions `.github/workflows/` で `--fail-on-findings` 付きで実行 -- md5等の非セキュリティ用途ハッシュには `usedforsecurity=False` を付与(bandit B303) From b6d4190b1786a5585773c6f0da14ab2ebdd24a4c Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 23:02:12 +0900 Subject: [PATCH 19/23] =?UTF-8?q?=F0=9F=93=9A=20docs:=20add=20tech-public.?= =?UTF-8?q?md=20with=20ASH=20scanning=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .kiro/steering/tech-public.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .kiro/steering/tech-public.md diff --git a/.gitignore b/.gitignore index cdbc9a9..81bf7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ web-ui/public/aws-exports.json !.kiro/steering/ .kiro/steering/* !.kiro/steering/principles.md +!.kiro/steering/tech-public.md !.kiro/steering/versioning.md !.kiro/steering/steering-policy.md diff --git a/.kiro/steering/tech-public.md b/.kiro/steering/tech-public.md new file mode 100644 index 0000000..dc14995 --- /dev/null +++ b/.kiro/steering/tech-public.md @@ -0,0 +1,14 @@ + + +# Tech (Public) + +## Deployment +- WebUI: `AWS_DEFAULT_REGION= bash scripts/deploy_webui.sh` +- CDK stacks: SdpmWebUi, SdpmAgent, SdpmRuntime, SdpmPngWorker, SdpmData, SdpmAuth + +## Security Scanning +- ASH (Automated Security Helper) v3 +- Local: `ash scan --mode local --fail-on-findings` +- Install: `alias ash="uvx git+https://github.com/awslabs/automated-security-helper.git@v3"` +- CI: GitHub Actions `.github/workflows/` で `--fail-on-findings` 付きで実行 +- md5等の非セキュリティ用途ハッシュには `usedforsecurity=False` を付与(bandit B303) From d8609c0571bd838bf416baf0ef81c5288d617583 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 23:05:15 +0900 Subject: [PATCH 20/23] =?UTF-8?q?=F0=9F=93=9A=20docs:=20add=20pre-PR=20loc?= =?UTF-8?q?al=20check=20to=20principles.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/principles.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.kiro/steering/principles.md b/.kiro/steering/principles.md index 00f3e18..210123d 100644 --- a/.kiro/steering/principles.md +++ b/.kiro/steering/principles.md @@ -65,3 +65,11 @@ Do not share: 1. Does the logic exist in the Engine? → Use it 2. Is it infrastructure-dependent? → S3/DynamoDB dependencies allow MCP Remote independent implementation 3. Is the difference only in presentation/output? → Engine API returns data, each layer controls output + +## PR前ローカルチェック + +PR作成前に以下をローカルで実行し、CI待ちを減らす: + +```bash +ash scan --mode local --fail-on-findings +``` From b30c0a536d056c458cbdfa33c2234a00569de87e Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Apr 2026 23:19:02 +0900 Subject: [PATCH 21/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20new=20deck=20animation=20+=20template=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnimatedSlidePreview: reset lastComposeUrlRef when skipAnimation transitions true→false - generate.py: fallback template blank-dark instead of non-existent default SPEC: 20260413-0806_svg-composition-animation --- mcp-server/tools/generate.py | 2 +- web-ui/src/components/deck/AnimatedSlidePreview.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mcp-server/tools/generate.py b/mcp-server/tools/generate.py index a08c6ab..6a97ed1 100644 --- a/mcp-server/tools/generate.py +++ b/mcp-server/tools/generate.py @@ -155,7 +155,7 @@ def _prepare_workspace( template_key = t.get("s3Key", "") break if not template_key: - template_key = deck.get("templateS3Key", "templates/default.pptx") + template_key = deck.get("templateS3Key", "templates/blank-dark.pptx") template_path = tmpdir / "template.pptx" template_path.write_bytes(storage.download_file(key=template_key)) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index 6fb119b..c837f54 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -91,8 +91,12 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimati // Track latest props in refs so the interval can read them without re-triggering const composeUrlRef = useRef(composeUrl) const defsUrlRef = useRef(defsUrl) + const skipRef = useRef(skipAnimation) composeUrlRef.current = composeUrl defsUrlRef.current = defsUrl + // When skipAnimation transitions false→true (deck ready), re-check current URL + if (skipRef.current && !skipAnimation) lastComposeUrlRef.current = "" + skipRef.current = skipAnimation useEffect(() => { let cancelled = false @@ -122,7 +126,7 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimati // skipAnimation = instant. Otherwise use backend changed flag. const animTargets = new Set() - if (!skipAnimation) { + if (!skipRef.current) { data.components.forEach((comp, i) => { if (comp.changed) animTargets.add(i) }) From 630eb232efa8cff3984a1f45da8f0e6e08dce140 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Tue, 14 Apr 2026 00:45:50 +0900 Subject: [PATCH 22/23] =?UTF-8?q?=F0=9F=90=9B=20fix(svg-composition-animat?= =?UTF-8?q?ion):=20hadSlidesOnMount=20for=20skip/animate=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Existing deck (slides on mount) → first compose instant, subsequent animate - New deck (no slides on mount) → all composes animate - No localStorage, no complex state — just mount-time fact SPEC: 20260413-0806_svg-composition-animation --- .../components/deck/AnimatedSlidePreview.tsx | 3 --- web-ui/src/components/deck/SlideCarousel.tsx | 18 ++++++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web-ui/src/components/deck/AnimatedSlidePreview.tsx b/web-ui/src/components/deck/AnimatedSlidePreview.tsx index c837f54..90df892 100644 --- a/web-ui/src/components/deck/AnimatedSlidePreview.tsx +++ b/web-ui/src/components/deck/AnimatedSlidePreview.tsx @@ -94,8 +94,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimati const skipRef = useRef(skipAnimation) composeUrlRef.current = composeUrl defsUrlRef.current = defsUrl - // When skipAnimation transitions false→true (deck ready), re-check current URL - if (skipRef.current && !skipAnimation) lastComposeUrlRef.current = "" skipRef.current = skipAnimation useEffect(() => { @@ -124,7 +122,6 @@ export function AnimatedSlidePreview({ defsUrl, composeUrl, slideId, skipAnimati cleanup() - // skipAnimation = instant. Otherwise use backend changed flag. const animTargets = new Set() if (!skipRef.current) { data.components.forEach((comp, i) => { diff --git a/web-ui/src/components/deck/SlideCarousel.tsx b/web-ui/src/components/deck/SlideCarousel.tsx index 169d82d..973f307 100644 --- a/web-ui/src/components/deck/SlideCarousel.tsx +++ b/web-ui/src/components/deck/SlideCarousel.tsx @@ -56,7 +56,9 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo /* ── Compose update detection → auto-scroll to changed slide ── */ const prevComposeKeys = useRef>(new Map()) const scrollTargetRef = useRef(undefined) - const deckReadyRef = useRef(false) + // If deck opened with slides → existing deck → first compose is instant + const hadSlidesOnMount = useRef(slides.length > 0) + const firstComposeSeenRef = useRef(false) useEffect(() => { let anyChanged = false @@ -64,12 +66,16 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo const key = slide.composeUrl?.split("?")[0] || "" const prev = prevComposeKeys.current.get(slide.slideId) || "" if (key && prev && key !== prev) anyChanged = true - // New slide added after deck was ready - if (key && !prev && deckReadyRef.current) anyChanged = true + if (key && !prev && firstComposeSeenRef.current) anyChanged = true if (key) prevComposeKeys.current.set(slide.slideId, key) } - if (!deckReadyRef.current && slides.some(s => s.composeUrl)) { - deckReadyRef.current = true + // Mark first compose seen (skip animation for existing decks) + if (!firstComposeSeenRef.current && slides.some(s => s.composeUrl)) { + if (hadSlidesOnMount.current) { + // Existing deck: suppress animation for this first batch + anyChanged = false + } + firstComposeSeenRef.current = true } if (anyChanged) scrollTargetRef.current = null // arm scroll for next onAnimate }, [slides]) @@ -337,7 +343,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo defsUrl={defsUrl} composeUrl={slide.composeUrl} slideId={slide.slideId} - skipAnimation={!deckReadyRef.current} + skipAnimation={hadSlidesOnMount.current && !firstComposeSeenRef.current} onAnimate={() => handleAnimate(slide.slideId)} fallback={ Date: Tue, 14 Apr 2026 00:50:56 +0900 Subject: [PATCH 23/23] =?UTF-8?q?=F0=9F=A7=B9=20cleanup(svg-composition-an?= =?UTF-8?q?imation):=20fix=20indent=20+=20hoist=20import=20re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lint/bias block: 2-space → 4-space indent consistency - Move 'import re' outside for-loop SPEC: 20260413-0806_svg-composition-animation --- mcp-server/server.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index d240d07..3e52447 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -710,24 +710,24 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, # Lint (filter to measured slides; lint uses 0-based index) if measure_slides: - try: - from sdpm.schema.lint import lint as lint_slides - presentation = json.loads((tmpdir / "presentation.json").read_text(encoding="utf-8")) - slide_set = set(measure_slides) - lint_diag = [d for d in lint_slides(presentation) if d.get("slide") + 1 in slide_set] - if lint_diag: - result["errors"] = {"lintDiagnostics": lint_diag} - except Exception as e: - logger.warning("Lint failed: %s", e) - - # Layout bias (filter to measured slides; bias uses 1-based) - try: - from sdpm.preview import check_layout_imbalance_data - layout_bias = [b for b in check_layout_imbalance_data(pptx_path, slide_defs=slides) if b.get("slide") in slide_set] - if layout_bias: - result["warnings"] = {"layoutBias": layout_bias} - except Exception as e: - logger.warning("Layout bias check failed: %s", e) + try: + from sdpm.schema.lint import lint as lint_slides + presentation = json.loads((tmpdir / "presentation.json").read_text(encoding="utf-8")) + slide_set = set(measure_slides) + lint_diag = [d for d in lint_slides(presentation) if d.get("slide") + 1 in slide_set] + if lint_diag: + result["errors"] = {"lintDiagnostics": lint_diag} + except Exception as e: + logger.warning("Lint failed: %s", e) + + # Layout bias (filter to measured slides; bias uses 1-based) + try: + from sdpm.preview import check_layout_imbalance_data + layout_bias = [b for b in check_layout_imbalance_data(pptx_path, slide_defs=slides) if b.get("slide") in slide_set] + if layout_bias: + result["warnings"] = {"layoutBias": layout_bias} + except Exception as e: + logger.warning("Layout bias check failed: %s", e) if save: # Compose: SVG → optimized JSON for WebUI animation @@ -751,6 +751,7 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, prev_by_hash: dict[str, list[dict]] = {} prev_by_slot: list[list[dict]] = [] # ordered by slide number prev_slot_map: dict[int, list[dict]] = {} + import re as _re for k in old_keys: if "/slide_" not in k: continue @@ -762,7 +763,6 @@ def run_python(code: str, deck_id: str | None = None, save: bool = False, if h: prev_by_hash[h] = comps # Extract slot number from key: slide_{N}_{epoch}.json - import re as _re m = _re.search(r"/slide_(\d+)_", k) if m: prev_slot_map[int(m.group(1))] = comps