# APP-3857: Memory-safe animated image rendering in markdown See `specs/kevinchevalier/APP-3857/PRODUCT.md` for the product spec. ## Problem Markdown image blocks currently pay for full animated-image decoding even though the markdown renderer does not actually play animations. `RenderableImage` builds a plain `warpui::elements::Image` without `enable_animation_with_start_time`, so the markdown surface effectively shows only the first frame today (`crates/editor/src/render/element/image.rs:13-49`, `crates/warpui_core/src/elements/image.rs:107-172`). However, the shared image asset path still decodes animated GIF/WebP assets into `AnimatedImage { frames: Vec<...> }`, and the resize path then creates resized copies of every decoded frame (`crates/warpui_core/src/image_cache.rs (259-359)`, `crates/warpui_core/src/image_cache.rs (457-511)`). On large assets this produces the multi-GB memory spike seen in `APP-3857`. ## Relevant code - `crates/editor/src/content/edit.rs:66-97` — resolves markdown image source strings into `AssetSource`. - `crates/editor/src/content/edit.rs:677-759` — converts `BufferBlockItem::Image` into a laid-out `BlockItem::Image` with default markdown image sizing. - `crates/editor/src/render/element/image.rs:13-49` — markdown render block that constructs `warpui::elements::Image` from the resolved asset source. - `crates/warpui_core/src/elements/image.rs:107-172` — animation only occurs when callers explicitly provide a start time; otherwise the first frame is painted. - `crates/warpui_core/src/image_cache.rs (259-359)` — `ImageType::try_from_bytes` currently uses `collect_frames()` for animated GIF and animated WebP inputs. - `crates/warpui_core/src/image_cache.rs (457-511)` — animated resize path duplicates every frame at the target render size. - `crates/warpui_core/src/image_cache.rs:666-772` — shared image-cache entry point; caches rendered images by asset source hash plus render properties and is where preview-vs-animation behavior can diverge without changing the underlying asset type. - `crates/warpui_core/src/assets/asset_cache.rs:284-320` — asset cache keys include both `AssetSource` and `TypeId`; this implementation continues to use the existing `ImageType` asset path and keeps full animated assets there. - `app/src/resource_center/section_views/changelog_section.rs:152` — an existing real app surface that explicitly enables animation and therefore must keep the current full-animation path. - `crates/warpui_core/src/image_cache_tests.rs (1-261)` and `crates/editor/src/content/markdown_tests.rs (270-337)` — the closest existing test coverage for image decoding/cache behavior and markdown image handling. ## Current state - Markdown image syntax is parsed as a block item and stored as `BufferBlockItem::Image { alt_text, source }`, then laid out as `BlockItem::Image { asset_source, config, .. }` using the shared editor/render pipeline (`crates/editor/src/content/text.rs:302-373`, `crates/editor/src/content/edit.rs:677-759`). - The markdown render element constructs a generic `warpui::elements::Image` with `.contain()` and no animation start time (`crates/editor/src/render/element/image.rs:42-49`). - The `Image` element will animate only if a caller explicitly opts in via `enable_animation_with_start_time`; otherwise it repeatedly paints frame 0 (`crates/warpui_core/src/elements/image.rs:107-172`). - Despite that static markdown UX, the shared asset loader eagerly decodes animated GIF/WebP files into an in-memory `AnimatedImage` containing all RGBA frames (`crates/warpui_core/src/image_cache.rs (259-359)`). - `ImageCache::image` then resizes animated assets by resizing every frame, which creates a second full set of frame buffers at markdown display size (`crates/warpui_core/src/image_cache.rs (457-511)`, `crates/warpui_core/src/image_cache.rs:666-772`). - This is why the current surface is especially wasteful: markdown gets only a first-frame preview, but pays the memory cost of full animation decode plus resized-frame duplication. ## Proposed changes - Add an explicit animated-image behavior to `warpui::elements::Image`, with two modes: - `FullAnimation` — current behavior for surfaces that intentionally animate. - `FirstFramePreview` — render animated sources as a static first-frame image. - Keep `FullAnimation` as the default so existing animated callers continue to work unchanged. Markdown will opt into `FirstFramePreview` at the call site. - Keep a single `ImageType` asset path in `crates/warpui_core/src/image_cache.rs`. Animated GIF/WebP assets will continue to decode into `AnimatedImage` in the asset cache, but `ImageCache::image` will materialize `FirstFramePreview` requests as a static first-frame render result instead of resizing every decoded animation frame. - Thread the new behavior into `ImageCache::image` so the rendered-image cache chooses between a static preview and a full animation from the same underlying `ImageType` asset. - Replace the current rendered-image cache key shape with a struct that includes, at minimum: - asset source hash - target bounds - animated-image behavior This avoids collisions where the same source and bounds are requested once as a full animation and once as a static preview. - Update `crates/editor/src/render/element/image.rs` so markdown image blocks always construct `Image::new(asset_source).contain().first_frame_preview()` (or equivalent builder naming). No markdown storage, export, or copy behavior changes are needed. - Leave explicit animated callers unchanged. `app/src/resource_center/section_views/changelog_section.rs:152` should continue to opt into animation, and the `warpui` animated-image example should still exercise the full-animation path. - Do not implement a bounded frame-buffer animation system in this ticket. That is the browser / media-viewer style solution, but it is unnecessary for this markdown surface because markdown does not animate today. The simpler preview-only split matches the current product behavior and removes the blow-up at the source. - This mirrors common approaches elsewhere: - browser/media stacks like Chromium separate decode from caching and leave room for smarter bounded frame caches - native image libraries often provide either a rolling frame buffer for true animation (for example Gifu) or a first-frame-only preview mode for list/thumbnail surfaces (for example Kingfisher) ## End-to-end flow ```mermaid graph TD A[Markdown ![](...)] --> B[BufferBlockItem::Image] B --> C[LayoutTask::Image resolves AssetSource] C --> D[BlockItem::Image] D --> E[RenderableImage] E --> F[warpui::elements::Image with FirstFramePreview] F --> G[ImageCache preview path] G --> H[AssetCache.load_asset] H --> I[Decode AnimatedImage once] I --> J[ImageCache extracts and resizes only frame 0] J --> K[Paint as static markdown image] L[Changelog / explicit animated surface] --> M[Image.enable_animation_with_start_time] M --> N[ImageCache full-animation path] N --> O[AssetCache.load_asset] O --> P[AnimatedImage] ``` ## Risks and mitigations - **Risk: preview/full cache collisions.** - Mitigation: include animated-image behavior in the rendered-image cache key rather than relying on `AssetSource + bounds` only. - **Risk: the asset cache still holds full animated frame data.** - Mitigation: acceptable for this version of the fix. The user-facing memory win comes from avoiding resized copies of every frame in `ImageCache`, and we add tests that preview requests still render static output while the asset cache keeps the full animated asset. If this is still too expensive in practice, a follow-up can teach `AssetCache` to cache raw bytes or accept decode parameters. - **Risk: accidental regression for existing animated surfaces.** - Mitigation: keep `FullAnimation` as the default, make preview behavior opt-in at the markdown call site, and add tests that the explicit animated path still produces `Image::Animated`. - **Risk: very large single-frame assets remain expensive.** - Mitigation: acceptable for this ticket because the current failure is multi-frame explosion. Follow-up work can add explicit decode limits or dimension guards if needed. - **Risk: first-frame choice differs slightly from a browser’s composited “current frame” semantics for malformed files.** - Mitigation: the product spec only requires a stable first visible frame preview, not browser-perfect animation fidelity. ## Testing and validation - Add `warpui_core` unit tests with small animated GIF and animated WebP fixtures: - preview policy returns `Image::Static` - full-animation policy still returns `Image::Animated` - preview policy does not regress static formats - Add an image-cache test that preview and full-animation requests for the same source/bounds do not collide in the rendered-image cache. - Add/extend markdown image tests only where needed to confirm markdown export and serialization still preserve the authored `![](...)` reference. - Manually validate against the Petra repro markdown from `APP-3857`: - open the document - scroll through it - confirm the old multi-GB spike is gone - confirm animated image sources render as stable first-frame previews - Run the usual repo validation before implementation is considered done: - `cargo fmt` - targeted Rust tests for the touched editor / warpui_core modules - wasm build check for this repo, since image/rendering code is shared across desktop and wasm-adjacent targets ## Follow-ups - If product later wants real markdown animation, add a bounded frame-buffer animation path instead of reusing the eager all-frames asset. - Add explicit decode limits / pixel-budget guards for pathological single-frame assets if the Petra fix reveals a second-order issue there. - Consider reusing the preview-only animated-image behavior in any other list, thumbnail, or markdown-adjacent surfaces that currently do not need full animation.