
Animated, interactive, runtime-editable SVGs rendered as live vectors; shape-cropped images/videos, GIF playback, morphing clip masks, depth-managed draggable layers, AI-friendly declarative API.
A Kotlin Multiplatform media library for Compose — with a focus on animated, interactive, and runtime-editable vector SVG, plus image processing, animated GIFs, audio, video, an on-device compositor with GIF/MP4 export, and a motion & action-FX set, on Android and iOS.
Everything renders into native Compose primitives (Canvas/drawScope), so SVGs are live vectors you can animate, drag, layer, and edit at runtime — not rasterized bitmaps.
Basic "show an SVG on both platforms" is now a solved problem (Coil 3, Kamel, Landscapist, Compose Multiplatform resources). KMPMedia targets what those still don't do — full breakdown with versions + sources in docs/COMPARISON.md:
| Capability | KMPMedia | Coil 3 (coil-svg) |
Compose MP resources |
|---|---|---|---|
| Cross-platform SVG (Android + iOS) | ✅ | ✅ | |
| Rendered as live vectors (not a bitmap) | ✅ | ❌ (rasterized) | ❌ |
Runtime SVG animation (SMIL <animate>) |
✅ | ❌ | ❌ |
Wrap any static image/SVG → animated (OGAnimatedImage) |
✅ | ❌ | ❌ |
| Shape-crop image & video to any shape (circle/triangle/…) | ✅ | ❌ | ❌ |
Free-form polygon lasso — clip to any AI-/hand-drawn outline (OGPolygonShape) |
✅ | ❌ | ❌ |
Auto-cutout — turn a segmentation mask into a live lasso (autoCutoutPolygon) |
✅ | ❌ | ❌ |
Live camera clipped to any shape (OGCameraPreview) |
✅ | ❌ | ❌ |
Depth / layer management — fly over/under + depth-of-field blur + parallax (Modifier.ogDepth / ogParallax) |
✅ | ❌ | ❌ |
Data-defined drawing styles — JSON {op} pipeline → live boil / quantize / pixelate (OGStyleSpec) |
✅ | ❌ | ❌ |
| Interactive / draggable / gesture layers | ✅ | ❌ | ❌ |
Any graphic → a tappable button — shape-aware tap + press effects (Modifier.ogButton) |
✅ | ❌ | ❌ |
On-device compositor + export — layer timeline with keyframes → animated GIF & H.264 MP4 (OGComposition) |
✅ | ❌ | ❌ |
Motion & action FX — slash ribbons, forked lightning, speed-lines, glow / bloom (com.solidkey.painpoints.fx) |
✅ | ❌ | ❌ |
Data-defined particles & parametric shapes (OGParticleSpec / OGParametric) |
✅ | ❌ | ❌ |
Live colour looks + blend modes — data-defined grade (OGLookSpec / Modifier.ogLook) |
✅ | ❌ | ❌ |
Soft / gradient & multi-region masks (ogSoftClip / OGMultiRegionShape) |
✅ | ❌ | ❌ |
Audio-reactive vectors — bind live sound (bass/mid/treble/level + beat) to any vector (OGAudioAnalyzer / Modifier.ogAudioReactive) |
✅ | ❌ | ❌ |
Motion design — draw-on strokes, motion paths (glide + orient along a curve), staggered timing (OGMotionPath / Modifier.ogMotionPath / OGStagger) |
✅ | ❌ | ❌ |
Motion dynamics — natural "feel" filters: spring (lag / overshoot / settle), follow-through, squash & stretch, idle sway (OGSpring / OGFollowChain / OGSquash / OGSway) |
✅ | ❌ | ❌ |
Cloth — a 1-D Verlet fabric strip that hangs, holds its length & flutters in the wind: scarf / flag / banner / hair (OGClothStrip / OGClothSpec) |
✅ | ❌ | ❌ |
Cloth sheet — a 2-D Verlet sheet that drapes over & collides with capsule "poles": tarp / curtain / banner / flag (OGCloth / OGClothCollider) |
✅ | ❌ | ❌ |
Photo → an editable vector SCENE — gradients, texture, glow, scatter + make-it-live motion day→night (OGSceneSpec) |
✅ | ❌ | ❌ |
Clickable-joints mesh rig — bind joints & bend a dense mesh live (OGRig) |
✅ | ❌ | ❌ |
| Bundled image + audio + video suite | ✅ | ❌ | ❌ |
If all you need is a static SVG loaded from the network, a general image loader is the simpler choice. Reach for KMPMedia when you need media to move, respond, compose, or change at runtime.
Compared above against the two most-cited general options; the other KMP libraries are complementary and narrower — Kamel / Landscapist are image loaders (load + cache, static SVG, animated GIF), Compottie plays pre-authored Lottie / After-Effects animations (not runtime-authored media), Telephoto is a zoom/pan viewer. None provide the compositor + export, media-in-shape, runtime-editable vectors, FX or rig above. Verified 2026-10-11: Coil 3.6.3, Kamel 1.0.9, Landscapist 2.13.2, Compottie 2.3.3, Telephoto 0.19.0, Compose Multiplatform 1.12.1 — note Coil's animated GIF and video are Android-only. Full matrix + sources: docs/COMPARISON.md.
KMPMedia is built to be generated correctly by AI coding assistants, not just written by hand:
<svg> (including the animated SMIL subset) or a URL, and KMPMedia renders and animates it live on both platforms.OG…, and you describe what (a shape, an animation set, a cue at a timestamp, a depth) as data. Generated code compiles more often and hallucinates less surface.llms.txt API index and an AI coding guide with prompt→snippet examples.OGAiVector is a stable JSON wire format (a codec, not an AI SDK) for the library's vector primitives. It hands a model that contract and turns the reply, or a photo via a vision model (image → vector, v1.25.0), into a live polygon lasso, a multi-region scene cut-out, or an SVG node patch ("describe → shape / patch"); an optional centripetal-spline smoothing knob rounds a model's faceted points into a clean outline. No AI SDK or networking is pulled into the library, and it works with any model (or a segmentation network that emits vertices).OGStageAuthor turns a plain sentence into a playable OGStage movie, and OGOfflineStageAuthor does it with no model, no key and no network at all — it reads a description for mood / time-of-day / subject and assembles a real movie (day→night scene + atmosphere particles + an optional FX beat + a styled title) through the ordinary OGStages builders. That is the honest floor: the loop always works. Wire your own language model, cloud or fully on-device, into the same loop via OGStageModel / OGStageAuthoring.resilient(...) (bring your own key, the call stays in your app) for richer movies in the identical format. See docs/AI_AUTHORING.md.Today that makes KMPMedia AI-generatable and AI-drivable at runtime, including a full serializable scene-spec (OGSceneSpec, shipped in v1.25.0) that now also carries gradient fills, texture, glow and scatter and its own motion (a day→night scene timeline, v1.38–v1.40). An MCP server (an LLM emits validated data, not Kotlin, and previews it before writing code) remains on the roadmap to make it fully AI-ready.
KMPMedia ships with a full Compose Multiplatform demo app (Android + iOS) built directly against this library's source, so every screen is real, readable library code you can lift into your own app. It's the fastest way to see what "animated, interactive, runtime-editable" actually looks like.
The core moves, live. Crop a photo into any shape · re-frame a running video into any shape · bring a 100%-static SVG to life with Scale / Rotate / Fade / Slide. Each is one⏱️ A note on the GIFs' speed. GIF is a low-frame-rate, palette-limited format, and the shape/path morph clips here are additionally slowed (~2–3×) on purpose, so the shape-shift is easy to follow in a static browser — those morph captures are not the real speed. (The gameplay/feature clips play at real speed.) Running live, every screen animates at your display's refresh rate (~60fps) and is smooth, not steppy: each morph resamples its two endpoint outlines once (cached) and then only interpolates points per frame — no re-parse, no per-frame allocation. So the slowed morph captures read clear-and-slow on purpose; the actual app is fluid.
OGImageView / OGAVPlayer / animation-primitive call — the same Compose Multiplatform code on Android & iOS.
The capstone — one JSON → a whole movie, then exported — and a language model can write that JSON from one line. A single OGStage document stacks a day→night scene, drifting petals, a forked lightning bolt and styled vector-text titles on one timeline; one player plays it at 60fps and scrubs it, and it renders off-screen to a shareable GIF/MP4 on device — every layer kind baked in, identically on Android & iOS:
OGSceneSpec + OGParticles petals + an OGLightning bolt + styled vector text titles), authored as JSON — a language model can write the whole movie from one line via OGStages.stagePrompt() — round-tripped through the OGStages codec, played by OGStageView at 60fps (scrubbable), then rendered off-screen and encoded to these very GIFs (the iOS one rendered headless on the simulator through the same exportGif()). The generate → play → export loop no Lottie/Rive runtime does. Same code on Android & iOS.
…and now you can poke it — a movie you watch becomes one you touch (v1.44.0):
Tappable layers — the Rive gap, closed. AnyOGStageLayer can carry an onTap trigger, plain serializable data that round-trips through the codec (a model authors it too). A tap springs the layer to life — BOUNCE / SHAKE / SPIN are closed-form reactions reusing the dynamics pack's OGSpring; SEEK / PLAY_PAUSE drive the player clock. Live-player only and zero idle cost — a still, tappable stage waits for a finger before it moves.
And the flagship runtime-editable SVG — one .svg parsed once, then any node changed by its id at runtime, bound to Compose state:
.svg is parsed once; a slider only mutates an overrides map keyed by node id and the same drawing redraws live — the needle rotates and the arc + status dot recolour green→amber→red. No re-parse, identical on both platforms. See docs/RUNTIME_SVG.md.
…and the same mechanism morphs a path between two shapes — set a <path>'s pathDataTo and drive morphProgress 0→1 from any Compose animation:
<path> tweens between a star and a ring and back, forever. Both endpoint d strings are parsed once and cached, so each frame only interpolates points — no re-parse, no allocation churn, built for the "runs in a game at 60fps" bar. Same OGSVGView + overrides path as the gauge above, identical on both platforms. See docs/RUNTIME_SVG.md.
…and the newest — morph the clip mask itself (v1.10.0). The same idea applied to clipping: the outline that masks a running video (or GIF) animates circle → diamond → triangle → lasso while the media plays on:
Morph the clip itself, live — Android. OneOGMorphShape drives the mask; the video keeps playing underneath as the outline tweens circle → diamond → triangle → lasso (the dark stage shows the silhouette at the corners). The same Shape feeds OGImageView.clipShape (photos/GIFs), OGPlayerConfig.clipShape (video) and any Modifier.clip — no new surface API. Each endpoint outline is resampled once (cached by shape + size) and only the points are lerped per frame, so it holds 60fps. See docs/SHAPE_MORPH_CLIPS.md.
…and the mask can now also be soft and multi-region (v1.12.0). A clip no longer has to be one shape with a hard edge: Modifier.ogSoftClip(shape, feather) fades a photo/GIF out over a feathered band (a vignette) and Modifier.ogGradientMask(brush) fades it along any gradient (edge fade, spotlight) — both surfaced on OGImageView as softEdge / maskBrush; and OGMultiRegionShape combines several placed sub-shapes with a path op (union / intersect / difference / xor) so one clip can show two portholes, or a diamond with a circular bite. It's an ordinary Shape, so it drops into the same clipShape slot for photos, GIFs and video. Soft masks are a single offscreen BlendMode.DstIn pass (no blur, no API floor); multi-region combines outlines once per size — both hold 60fps. See docs/SOFT_MASKS.md.
OGImageView: softEdge feathers the clip edge into a vignette, maskBrush fades the photo along a gradient, and OGMultiRegionShape clips it to more than one region (two portholes, or a diamond with a circular bite). Soft masks are one offscreen BlendMode.DstIn pass — no blur, no API floor; multi-region is a path op computed once per size. The same code on both platforms. See docs/SOFT_MASKS.md.
…and the newest, the flagship — compose layered scenes and export them (v1.13.0). KMPMedia could put one piece of media in one shape; now it composes several layers on one timeline, animates them with keyframes, previews the result live at 60fps, and exports it to a single shareable animated GIF — all authored on device. OGComposition stacks image / solid layers, each with animatable x / y / scale / rotation / opacity tracks and any shape clip over a [startMs, endMs] window; OGCompositionView plays it on the Compose frame clock (or scrubs to any positionMs); and OGComposition.exportGif() renders every frame offscreen and encodes one looping GIF with a pure-Kotlin encoder — zero dependencies, identical bytes on Android and iOS. New in v1.14.0: the same composition also exports to a real H.264 MP4 with OGComposition.exportMp4(), driving the device's own video encoder (Android MediaCodec + MediaMuxer, iOS AVAssetWriter) — still with no third-party dependency, since the compositing and colour maths stay in shared, tested commonMain. No other KMP library does compose-and-export. See docs/COMPOSITOR.md.
OGComposition timeline, each with keyframed x / y / scale / rotation / opacity and a shape clip, previewed at 60fps by OGCompositionView. In the app, Export renders the timeline and encodes it to a single looping animated GIF with the pure-Kotlin OGGifEncoder (median-cut palette + Floyd–Steinberg dithering + LZW, zero deps, so gradients stay band-free) — then plays that exact GIF back through the platform's own decoder, proving it's a real, shareable file. The same code on both platforms. See docs/COMPOSITOR.md.
Same composition, exported to a real H.264 MP4 — Android (left) & iOS (right). The identical keyframed scene, this time rendered by OGComposition.exportMp4() through the device's own video encoder (Android MediaCodec + MediaMuxer, iOS AVAssetWriter) — no third-party dependency. On Android the exported file is played straight back in OGAVPlayer, proving it's a real, shareable .mp4; the iOS capture shows the same export producing a valid H.264 .mp4 (standard High profile, 288×288 @ 20fps). The compositing and colour maths (RGB→YUV / BGRA) live in shared, tested commonMain; only the thin encoder is per-platform. New in v1.14.0. See docs/COMPOSITOR.md.
…and that clipped media is now interactive — grab, drag, pinch-zoom, twist-rotate, fling and spring back (v1.15.0). Modifier.ogInteractive(state, hitArea) makes any composable gesture-driven: it applies the pan / zoom / rotation through a single graphicsLayer (a GPU-layer transform, no recomposition, 60fps) and drives it from a custom awaitEachGesture loop, with a momentum fling on release and a spring settle back inside its bounds. Pass an OGHitArea and the touch is shape-aware — a drag only starts inside the clip's silhouette, so the transparent corners of a triangle / circle / lasso fall through instead of grabbing it. Pan limits are a fraction of the content size, so one config works at any resolution; the hit-test and clamp maths are unit-tested on JVM + iOS. No other KMP media library makes a shape-clipped layer physically interactive. See docs/INTERACTIVE.md.
Modifier.ogInteractive, with a momentum fling and a bouncy spring settle. OGHitArea makes the touch shape-aware (grab the silhouette, not its bounding box). The motion here is driven by a scripted loop so both platforms show the full pan / zoom / rotate / spring identically (the capture tooling can't inject multi-touch); in the app it's your fingers. The transform rides one GPU graphicsLayer, so it holds 60fps; the hit-test and clamp maths are unit-tested on JVM + iOS. New in v1.15.0. See docs/INTERACTIVE.md.
…and the newest, most-animated additions now have GIFs — captured straight from the demo (Android shown; almost all are pure shared commonMain maths drawn by the same Skia engine, so the iOS frame is identical — the two platform-backed exceptions, bloom's blur and styled text's base font, are noted below; run the demo to see any of them live on either OS):
ogBloom's halo is a real platform gaussian blur (Android RenderEffect 31+ / iOS Skia), so it's visually equivalent rather than pixel-identical.
✍️ Motion design — draw-on, motion paths & stagger. A stroke that draws itself on by arc length, an element gliding + turning along a curve, and staggered group timing — all normalized 0..1, pure arc-length maths, 60fps, same code on Android & iOS. See docs/MOTION.md.
🌀 Motion dynamics — robotic → alive. The same moving target chased by a robotic twin (constant speed, stops dead) and one fed through the dynamics layer: spring lag / overshoot / settle, squash & stretch, a follow-through comet tail and idle sway, switchable between five feel presets. Pure closed-form maths (so the compositor can scrub + export it), 60fps, same code on Android & iOS. See docs/MOTION.md.
🎬 Composing it all — a walk cycle at sunset. Not one primitive but many at once: the 1.35.0 dynamics driving a gait (OGSpring follow-through on the knees/elbows + OGSquash footfall), a 1-D OGClothStrip scarf and a 2-D OGCloth flag (v1.37.0) both rippling in the wind, OGSway head drift, OGSpeedLines, OGParticles petals, and a sun that sets as an animated ogLook grade warms then cools the whole frame day → dusk → night — a photo head clipped to a lasso riding along. Every effect here is a shipped library primitive; the rig + sunset choreography are ~demo code. See the demo's 🎬 Scene in motion screen.
🪄 Auto-cutout → live lasso. The background is removed and the silhouette traced to a polygon lasso that drops into the same clipShape slot (shown on a synthetic subject so it reproduces without a photo). A still — the result itself doesn't animate.
Here's the full rundown of this wave (📷 live camera is the one effect not shown here — it needs a real device's camera, so it stays described):
OGSegmenter seam (plug in ML Kit / Vision / a cloud model) plus a cross-platform mask → lasso tracer (flood-fill → Moore-neighbour trace → Douglas–Peucker simplify) whose output drops straight into the same clipShape slot. No ML model is bundled. See docs/AUTO_CUTOUT.md.OGCameraPreview(shape, facing) masks the live camera feed to any shape (a built-in OGShapeType, an OGPolygonShape lasso, CircleShape, …): the AR-sticker primitive, built on the platform camera APIs (Camera2 / AVFoundation) with no third-party dependency. See docs/CAMERA.md.OGStyleSpec is a JSON pipeline of {op, params} that OGStyles.decode() compiles into a live OGStyle applied every frame — boil (a living, hand-drawn line), quantize (a stepped, stop-motion line) and pixelate (a low-res mosaic fill). A designer or a language model can author and share a .style pack with no code and no rebuild, mirroring how OGAiVector turns a model's JSON into shapes. Zero new dependency; the demo's 🖊️ Boiling lines screen (with a live "Style from JSON" editor) dogfoods it. See docs/STYLES.md.Modifier.ogButton(hitArea, pressEffect) { onClick() }, makes any graphic (a photo, an SVG, a lasso-cut cut-out, a Canvas drawing) a real, accessible button. It's the tap twin of Modifier.ogInteractive — both reuse OGHitArea, so a tap only counts inside the silhouette and the transparent corners fall through. Pick a press effect — Scale, Dim, Brutalist (a pop-art hard-shadow push-in it draws itself) or None — add an optional long-press, and it's done. Zero dependency; the demo's 🔘 Any graphic → a button screen dogfoods it. See docs/BUTTON.md.OGParticleSpec (rate, gravity, colour-over-life, shape) compiled once into a OGParticleView (a struct-of-arrays sim, no per-frame allocation, deterministic from a seed), and parametric outlines (star / gear / flower / superellipse / blob) wrapped in an OGPolygonShape clipShape. Author-as-JSON, 60fps, same on Android & iOS. See docs/PARTICLES.md · docs/PARAMETRIC_SHAPES.md.OGStyledText vectorizes a string once and runs each glyph outline through a drawing style (roughen / boil / wave / …) every frame, so the letters are hand-inked and alive — a title card or a word-game tile. See docs/STYLES.md.com.solidkey.painpoints.fx family for composing stylised action scenes: OGSlashView (a breathing-slash ribbon — water / flame / thunder by data), OGLightningView (forked, fractal lightning), OGSpeedLinesView (manga impact lines), elemental particle presets (petals / embers / droplets / leaves), and Modifier.ogGlow / Modifier.ogAfterImage. Each effect is serializable data; all pure maths, 60fps, frame-identical on Android & iOS — no shaders, no new dependency. See docs/SLASH.md.Modifier.ogBloom(radius, intensity, color?) redraws a composable through a real platform gaussian blur (Android RenderEffect / iOS Skia), blended additively, for a soft luminous halo — the cinematic cousin of the zero-dependency, frame-identical ogGlow. Opt-in: the blur needs Android 31+ (it no-ops to a crisp draw below) and isn't pixel-identical across platforms. This release also repairs a 1.30.0 bug that silently no-op'd ogGlow and ogAfterImage, so the composite FX modifiers now render correctly. See docs/SLASH.md.OGLookSpec (brightness / contrast / saturation / temperature / tint / hue) compiles once into a single ColorFilter you drop on any graphic with Modifier.ogLook, plus a per-layer grade + blend modes on the compositor. One GPU colour op, zero per-frame allocation, 60fps, pixel-identical on Android & iOS; a look serializes to a .look JSON a model can author (like OGAiVector / OGStyles). See docs/LOOKS.md.OGFft) folds PCM into bass / mid / treble / level + a beat flag (OGAudioAnalyzer), smoothed with attack/release, and Modifier.ogAudioReactive binds a band to any composable (or read it straight into a spec). The capture is a pluggable OGAudioSource — a built-in deterministic OGSyntheticAudioSource runs it with no mic, and you feed your own mic/music tap on device, so no platform audio or permission enters the library. Zero-dep, 60fps, same code on Android & iOS. See docs/AUDIO_REACTIVE.md.com.solidkey.painpoints.motion: draw-on (a stroke or logo that draws itself on by arc length, OGDrawOnStroke), motion paths (send any element gliding along a curve at constant speed and turning to face its travel, Modifier.ogMotionPath), and stagger (one timeline → per-element offset timing so a group cascades / waves / pops, OGStagger). The path + stagger are plain serializable data (OGMotions), the core is pure arc-length maths (O(log n) per lookup, centripetal smoothing, no recomposition), 60fps, same code on Android & iOS. See docs/MOTION.md.com.solidkey.painpoints.motion: an analytic second-order spring (OGSpring — lag, overshoot, settle; closed-form so the compositor can scrub + export it, which androidx…spring() can't), follow-through (OGFollowChain, a trailing chain for hair / cape / limbs), volume-preserving squash & stretch (OGSquash) and organic idle sway (OGSway). The whole feel is data — an OGDynamicsSpec with five presets (bouncy / heavy / snappy / gentle / stiff) a model can author from a sentence (OGDynamics.dynamicsPrompt), the same pattern as OGLooks / OGMotions / OGStyles. Pure maths, zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.com.solidkey.painpoints.motion: OGClothStrip, a one-dimensional Verlet cloth strip pinned at the head that hangs under gravity, stays inextensible (four position-based distance-constraint passes, so it swings but never stretches) and flutters in a prevailing wind plus seeded OGFxNoise gusts — a scarf, flag, banner or lock of hair. A fixed internal substep makes it stable and byte-for-byte deterministic at any frame rate, and OGCloths.sampleAt(spec, t) re-simulates to a time so the compositor can scrub + export it. The strip is plain serializable data (OGClothSpec) with four ready fabrics (scarf / flag / banner / hair) a model can author (OGCloths.clothPrompt). Reach for it when something should hang and flutter; reach for OGFollowChain when something should stretch and trail. Zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.OGCloth, a 2-D planar Verlet sheet (a cols × rows grid of masses held by structural + shear springs, inextensible) that does the thing the 1-D strip can't — it drapes over and collides with OGClothCollider capsule "poles" (a line + radius; a circle is a zero-length capsule), so you can throw a tarp over a scaffold bar or fly a flag off a pole. Same fixed-substep determinism (byte-for-byte identical on Android & iOS), four pin modes (NONE / TOP_EDGE / TOP_CORNERS / LEFT_EDGE), and the same data-defined, AI-authorable codec (OGClothMeshSpec + OGCloths.meshPreset / clothMeshPrompt, presets tarp / curtain / banner-wall / sail) with OGCloths.sampleAt for deterministic scrub + export. Deliberately a planar media primitive — no 3-D cloth, self-collision, tearing or solver API. Zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.OGSceneSpec "photo → vector scene" format grew up. v1.38.0 adds per-shape gradient fills (OKLab-resampled, no banding) plus a scene background + colour grade; v1.39.0 adds richer stills — a procedural texture (fur / grass / grain), an additive glow halo, and a seeded scatter that stamps one shape into a whole field (trees / tufts); v1.40.0 makes it live — a region declares its own looping motion (translate / scale / rotate / fade) and the scene swings a colour grade + sky over time (day → dusk → night), all pure and t-addressable so the compositor can scrub + export it. Backward-compatible, pure codec, 60fps, same code on Android & iOS.OGRig binds a handful of joints to the mesh once (linear-blend skinning, partition-of-unity weights), then a drag warps the whole mesh around the joint, re-skinned every frame — the one case the rasterise-once / cache path can't cover. ~1.3 ms to skin 20k vertices; deformInto is zero-allocation, nearestJoint is the touch hit-test, and a released joint springs home with OGSpring. Pure maths, zero dependency, 60fps, frame-identical on Android & iOS.OGStage stacks solid / scene / particles / slash / lightning / speedlines / styled-text layers, each transformed by the compositor's keyframe tracks and graded by a look. One OGStageView plays it at 60fps and scrubs it; OGStage.exportGif() / exportMp4() render it off-screen with every layer kind baked in (the iOS README clip was rendered headless on the simulator through that very path). A language model authors the whole movie from one line via OGStages.stagePrompt() — the codec tolerates real model output (bare-number tracks, case-insensitive enums, markdown fences), and three model-written movies (STORM / HANAMI / RUSH) ship in the demo and are pinned as regression tests. v1.44.0 makes it interactive: any layer can carry an onTap trigger — tap it and it springs to life (BOUNCE / SHAKE / SPIN via the dynamics OGSpring, or SEEK / PLAY_PAUSE), the Rive-style interactivity the CMP players led on, as plain model-authorable data. v1.45.0 closes the describe→movie loop: OGStageAuthor turns a plain sentence into a playable stage, and OGOfflineStageAuthor does it with no model, no key and no network (a deterministic director that reads the words for mood / subject) — so the loop always works; wire your own model (cloud or on-device) into the same loop via OGStageModel / OGStageAuthoring.resilient(...) for richer movies. Thin orchestration, no new per-frame cost, same code on Android & iOS. See docs/STAGE.md and docs/AI_AUTHORING.md.(📷 Live camera is the one effect without a GIF here — it needs a real device's camera (there's nothing to film on a headless emulator), so run the demo on a device to see it live.)
And a whole mini-game built from those same primitives:
UFO Dodge, live — Android (left) & iOS (right). The same Compose Multiplatform code on both: a real looping video clipped into the backdrop, SVG sprites animated by the library, and the video's own timeline firing an in-game meteor-storm cue.And animated GIFs — one OGImageView pointed at a .gif, playing on both platforms (and clipped into shapes just like a still photo):
OGImageView pointed at a .gif URL — it auto-detects and loops the frames (Android AnimatedImageDrawable, iOS Skia Codec). The same shape clip that crops a still photo animates the moving frames inside a circle / diamond too. See docs/GIF.md.
Android — “Add Your Head to a Body”, then UFO Dodge.
iOS — the same Compose Multiplatform code on an iPhone simulator: “Add Your Head to a Body” (your photo cut out by the OGPolygonShape head-lasso, exactly as on Android), the home menu, then UFO Dodge (black-hole warp + a meteor-storm cue).
What's inside
<animate> tags) and bring it alive with the animation primitives: Scale / Rotate / Fade / Slide, combined live.id at runtime — recolour, rotate, move, reshape its path, or morph it smoothly between two shapes — bound to Compose state (OGSvgNodeOverride). A live gauge whose needle and zones follow a slider, and a star that tweens into a ring at 60fps, no re-parse. "SVG as a live template", identical on Android & iOS.OGPolygonShape) clips out just the head — no external editor — pinned at the neck joint you set (size + tilt); or fall back to a circle / triangle / diamond. Then tap Wave / Walk / Jumping jacks / Dance and the whole body animates, your head riding along. The head is one OGImageView clip; the rig and every dynamic are plain Compose, identical on Android & iOS.OGImageView at a .gif and it plays: looping frames on Android (AnimatedImageDrawable) and iOS (Skia Codec), the same code. The shape clip that crops a photo animates the moving frames inside a circle / diamond too.OGAVPlayer (ExoPlayer on Android, AVPlayer on iOS), re-framed live into any shape, with transport controls, loop and load-any-URL.OGMorphShape. Clipping moving media to a morphing outline at 60fps, identical on Android & iOS.softEdge), fade it along a gradient (maskBrush), or clip it to more than one region at once — two portholes, a diamond with a circular bite — with OGMultiRegionShape. Soft edges via one offscreen DstIn pass; multi-region via a path op computed once. Same code on Android & iOS.Modifier.ogInteractive adds the transform + momentum + spring; OGHitArea means only touches inside the actual silhouette grab it, not its bounding box. Zero-dep, 60fps, same code on Android & iOS.OGSegmenter seam (ML Kit / Vision / cloud) plus a zero-dependency mask → lasso tracer that feeds the same clipShape slot. No bundled ML model.OGCameraPreview(shape, facing) masks the live camera feed to any shape (built-in / lasso / CircleShape), front or back: the AR-sticker primitive, on Camera2 / AVFoundation with no third-party dependency.OGStyleSpec JSON pipeline of {op, params} (boil / quantize / pixelate) that OGStyles.decode() compiles into a live OGStyle applied every frame. Author and share .style packs with no code; the 🖊️ Boiling lines screen has a live "Style from JSON" editor.Modifier.ogButton: an SVG star, a lasso-cut photo head and a diamond, each with a different press effect (Scale / Dim / Brutalist) and shape-aware taps — tap the head and it fires; tap a transparent corner of the same photo and it falls through. The tap twin of ogInteractive, zero-dep, same code on Android & iOS.OGParticleView with a live JSON editor, and parametric outlines (star / gear / flower / squircle / blob) spun on the GPU — both author-as-JSON.OGSlashView water / flame / thunder slashes with live Width / Glow / Breathing sliders, a JSON editor, and composed droplet / ember / spark particles.OGLightningView lightning, manga OGSpeedLinesView impact lines, elemental particle presets, the ogGlow / ogAfterImage modifiers, and a composed "action beat" assembling several on one stage.onError fires cleanly; and load-timing / many-layer stress benchmarks with live numbers.Run it — the demo lives in its own companion repo and consumes this library via includeBuild:
./gradlew :androidApp:assembleDebug, install the APK, or open it in Android Studio. Prebuilt APKs are on the demo repo's Releases page.minSdk per catalog, compiled against JDK 17)iosArm64, iosSimulatorArm64)ℹ️ There are no JVM-desktop, web, or Linux targets in this build.
✅ Available on Maven Central. Add the coordinate below and you're set. If you'd rather build against the library source (or need an unreleased change), see
includeBuildor GitHub Packages below.
KMPMedia is a Compose Multiplatform library. In your settings.gradle.kts, make sure the Compose dev repo is available alongside the usual repositories:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}Then add the dependency to your shared module's commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("se.solidkey:kmpmedia-lib:1.41.0")
}
}
}Add the GitHub Packages repository (requires a GitHub token with read:packages):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven("https://maven.pkg.github.com/SolidKeyAB/kmpmedia") {
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_TOKEN")
}
}
}
}includeBuild)To hack on the library itself or consume an unreleased change, depend on a local checkout instead of Maven Central — this is exactly how the demo app uses it:
// settings.gradle.kts of the consuming project
includeBuild("../KMPMedia") // path to your local clone// consuming module's build.gradle.kts
commonMain.dependencies {
implementation("se.solidkey:kmpmedia-lib")
}import com.solidkey.painpoints.image.svg.OGSVGView
import com.solidkey.painpoints.image.loading.OGSvgUrlType
OGSVGView(
source = OGSvgUrlType("https://example.com/logo.svg"),
width = 240f,
height = 240f,
onError = { message -> /* handle load/parse errors */ },
)Sources can also be local files or bundled resources:
OGSvgFileType("/path/to/icon.svg")
OGSvgResourceFileType("icon.svg")import com.solidkey.painpoints.image.svg.animation.OGSVGAnimationPlayer
OGSVGAnimationPlayer(
source = OGSvgResourceFileType("spinner.svg"),
width = 120f,
height = 120f,
isPlaying = true,
loop = true,
onError = { /* ... */ },
)OGAnimatedImage wraps any source — a photo, or even a plain SVG with no <animate> tags — and animates it at runtime. The source file is never modified; the motion comes entirely from the wrapper.
import com.solidkey.painpoints.image.animating.OGAnimatedImage
import com.solidkey.painpoints.image.animating.OGAnimationType
import com.solidkey.painpoints.image.loading.OGImageUrlType
OGAnimatedImage(
source = OGImageUrlType("https://example.com/logo.png"),
animations = setOf(OGAnimationType.SCALE, OGAnimationType.ROTATE), // SCALE / ROTATE / FADE / TRANSLATE
durationMillis = 1200,
intensity = 1f,
onError = { /* ... */ },
)import com.solidkey.painpoints.image.OGImageView
import com.solidkey.painpoints.image.loading.OGImageUrlType
OGImageView(
source = OGImageUrlType("https://example.com/photo.jpg"),
draggable = true,
onEventTriggered = { event, id -> /* layer interaction events */ },
onError = { /* ... */ },
)Crop the photo into a shape (circle, triangle, diamond, …) with a GPU clip — drawn once, no bitmap cost:
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.solidkey.painpoints.shape.OGShapeType
OGImageView(
source = OGImageUrlType("https://example.com/portrait.jpg"),
modifier = Modifier.size(220.dp),
displayShape = OGShapeType.CIRCLE, // CIRCLE / TRIANGLE_UP / TRIANGLE_DOWN / DIAMOND / SQUARE / RECTANGLE
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter, // which region stays visible when the photo is over-scaled
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)The same shape-crop applies to video — a built-in shape via
OGAVPlayer(config = OGPlayerConfig(displayShape = OGShapeType.CIRCLE)), or a free-form lasso viaOGPlayerConfig(clipShape = OGPolygonShape.of(...))(the sameclipShapeoverride as images).
Need to keep only an arbitrary region — a head, a logo, a hand-drawn area? Clip to a free-form polygon lasso instead of a built-in shape. The outline is just a list of normalized 0..1 vertices joined by line segments, so an AI/segmentation model or an on-image finger-draw can produce it directly — no external editor:
import com.solidkey.painpoints.shape.OGPolygonShape
OGImageView(
source = OGImageResourceFileType("portrait", OGImageFormat.JPEG),
modifier = Modifier.size(220.dp),
contentScale = ContentScale.Crop,
clipShape = OGPolygonShape.of( // vertices in 0..1 space (top-left origin), any count ≥ 3
0.48f to 0.02f, 0.86f to 0.22f, 0.84f to 0.54f,
0.48f to 0.83f, 0.16f to 0.53f, 0.15f to 0.22f,
), // ← clips to exactly this outline; see docs/POLYGON_SHAPE.md
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)Point OGImageView at a .gif and it plays — no extra API. The frames animate on both platforms (previously the image path only ever showed the first frame). Everything else about OGImageView still applies: contentScale, alignment, and shape-crop / lasso clipping all work on the moving image.
import com.solidkey.painpoints.image.OGImageView
import com.solidkey.painpoints.image.loading.OGImageUrlType
// A .gif URL (or file path) auto-detects and loops.
OGImageView(
source = OGImageUrlType("https://example.com/loading.gif"),
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)Want direct control over looping / speed, or to feed the animation into your own Image? Use the underlying painter:
import androidx.compose.foundation.Image
import com.solidkey.painpoints.image.gif.rememberOGAnimatedPainter
import com.solidkey.painpoints.source.OGSource
val painter = rememberOGAnimatedPainter(
source = OGSource.Url("https://example.com/loading.gif"),
loop = true,
speed = 1.5f, // iOS honours speed; Android plays at native rate
)
painter?.let { Image(painter = it, contentDescription = null) }How it works — Android decodes into the platform's self-animating
AnimatedImageDrawable(API 28+; the static first frame on API 24–27). iOS decodes every frame with Skia'sCodecand cycles them honouring per-frame delays andspeed. See docs/GIF.md.
See docs/POLYGON_SHAPE.md for the full API and how the demo's "Add Your Head to a Body" uses it to cut out a head.
import com.solidkey.painpoints.video.playing.OGAVPlayer
import com.solidkey.painpoints.video.playing.OGAVPlayerAction
import com.solidkey.painpoints.video.loading.OGVideoUrlType
val result = OGAVPlayer(
action = OGAVPlayerAction.PLAY, // PLAY / PAUSE / STOP
source = OGVideoUrlType("https://example.com/clip.mp4"),
onError = { error -> /* DAVPlayerError */ },
)New in 1.1.0. Give any content a depth (0f = far/behind, 1f = near/front) relative to a focal plane, and KMPMedia derives all three depth effects from that one number: z-order (fly over/under), a depth-of-field blur + dim that grows with distance from focus, and an optional parallax scale.
The easy path — a field of objects that share one focal plane; each child just states its own depth:
import com.solidkey.painpoints.depth.OGDepthField
import com.solidkey.painpoints.depth.OGDepthObject
import com.solidkey.painpoints.depth.OGDepthConfig
OGDepthField(focalDepth = 0.5f) { // 0.5 is the plane in sharp focus
OGDepthObject(depth = 0.1f) { Backdrop() } // far → behind + blurred
OGDepthObject(depth = 0.5f) { Subject() } // in focus → crisp
OGDepthObject(depth = 0.9f, config = OGDepthConfig.Video) {
OGAVPlayer(...) // near, in front — Video preset: dim, no blur
}
}Or drop it onto any single composable:
import com.solidkey.painpoints.depth.ogDepth
OGImageView(..., modifier = Modifier.ogDepth(depth = 0.2f, focalDepth = 0.5f))Tune it with OGDepthConfig(maxBlur, minAlpha, dimFalloff, blurContent, depthScale); use OGDepthConfig.Video for video/native surfaces (dim + z-order, no blur — a RenderEffect blur over a TextureView/AVPlayerLayer is unreliable). In focus with defaults it applies only zIndex (no extra layer). Full perf + compatibility analysis in docs/DEPTH_LAYER.md.
New in 1.11.0. KMPMedia's vector primitives are just data, so a language model can produce them. OGAiVector is a provider-agnostic JSON interop layer: it hands a model the exact contract with polygonPrompt(...) / svgPatchPrompt(...), and turns the reply back into a live clip shape or SVG patch — tolerant of the code fences and prose models add. No AI SDK or networking is pulled into the library; you own the model and the call.
import com.solidkey.painpoints.ai.OGAiVector
// describe → clip region: the model's reply becomes an OGPolygonShape for any clipShape
val prompt = OGAiVector.polygonPrompt("the outline of a five-pointed star")
val star = OGAiVector.decodePolygonOrNull(myLlm.complete(prompt)) // your model, your call
// describe → SVG patch: constrain the model to the ids that exist, apply the result live
val patch = OGAiVector.svgPatchPrompt("point the needle to 80% and turn the arc amber",
nodeIds = listOf("needle", "arc"))
OGSVGView(source = OGSvgResourceFileType("gauge.svg"), width = 240f, height = 240f,
overrides = OGAiVector.decodeSvgPatchOrNull(myLlm.complete(patch)) ?: emptyMap())
// image → vector (new in 1.25.0): hand a VISION model a photo/frame, get editable vectors back
val imgPrompt = OGAiVector.imageToVectorPrompt(hint = "trace the person", imageInfo = OGImageInfo.of(photo))
val cutout = OGAiVector.decodePolygonOrNull(myVisionLlm.complete(imgPrompt, photo.toBase64Png()))encodePolygon / encodeSvgPatch / encodeScene go the other way (persist a lasso/scene, seed a prompt with the current state). New in 1.25.0: imageToVectorPrompt(...) + decodeScene(...) turn a photo (or a live frame) into editable vector shapes via any vision model — you attach the image with ImageBitmap.toBase64Png(), and the library still makes no network call. All of it runs at generate/patch time, never per frame. See docs/AI_HOOKS.md.
New in 1.23.0. A drawing style is just data — a JSON pipeline of {op, params}. OGStyles.decode(...) compiles it once into a live OGStyle; apply it each frame with a rising timeMs. The ops are boil (the living hand-drawn line), quantize (a stepped line) and pixelate (a low-res sprite fill); a designer or a model can author and share a .style pack with no code.
import com.solidkey.painpoints.style.OGStyles
val style = OGStyles.decode(
"""{"name":"pixel sprite","ops":[
{"op":"boil","amplitude":0.03,"boilFps":10},
{"op":"pixelate","resolution":20}
]}""",
)
val frame = style.apply(outline, timeMs) // outline: List<OGPoint> in 0..1 space
if (frame.isPixelated) { /* draw frame.pixelSize squares at frame.pixels */ }
else { /* stroke or fill frame.outline */ }Or give a media clip a living, hand-cut edge — a boiled OGPolygonShape drops into any clipShape slot, recreated each frame from a rising time:
import com.solidkey.painpoints.style.OGBoil
import com.solidkey.painpoints.style.boiled
OGImageView(
source = OGImageUrlType("https://example.com/photo.jpg"),
clipShape = OGPolygonShape(outline).boiled(OGBoil(amplitude = 0.02f), timeMs),
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)OGStyles.stylePrompt("a nervous pencil sketch") hands any model the exact JSON contract, so a style can be AI-authored too. See docs/STYLES.md.
New in 1.24.0. Modifier.ogButton makes any graphic a real, accessible, shape-aware button — no UI component, no theme system. Pass an OGHitArea matching the clip and only taps inside the silhouette count; the transparent corners fall through. Pick a press effect and you're done.
import com.solidkey.painpoints.gesture.ogButton
import com.solidkey.painpoints.gesture.OGHitArea
import com.solidkey.painpoints.gesture.OGPressEffect
OGImageView(
source = photo,
clipShape = headLasso, // the visible cut-out
modifier = Modifier
.size(160.dp)
.ogButton(
hitArea = OGHitArea.polygon(headLasso), // only the head is tappable
pressEffect = OGPressEffect.Brutalist(), // pop-art hard-shadow push-in
onLongClick = { showOptions() },
) { open() },
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)It's the tap twin of Modifier.ogInteractive (the drag half) — both reuse OGHitArea. Other press effects: OGPressEffect.Scale() (a squeeze), OGPressEffect.Dim() (a fade), OGPressEffect.None. See docs/BUTTON.md.
The SVG engine is a pure-Kotlin parser + renderer. Honest status:
Supported
M L H V C Q Z and arcs (A, approximated with béziers)rect (incl. rx/ry), circle, ellipse, line, polygon, polyline
<g>, <use>, <symbol>
translate, scale, rotate, skewX/Y, matrix
<animate>) and interactive/draggable layersPartial / not yet supported
<clipPath>, <mask>
<text> / <tspan>
<image>Anything not listed above as working should be treated as unsupported for now.
Pre-1.0 in spirit — the SVG animation/interaction layer is the actively developed core; the audio/video/image pieces are functional wrappers over platform players. See the CHANGELOG for release history.
MIT © SolidKey AB
A Kotlin Multiplatform media library for Compose — with a focus on animated, interactive, and runtime-editable vector SVG, plus image processing, animated GIFs, audio, video, an on-device compositor with GIF/MP4 export, and a motion & action-FX set, on Android and iOS.
Everything renders into native Compose primitives (Canvas/drawScope), so SVGs are live vectors you can animate, drag, layer, and edit at runtime — not rasterized bitmaps.
Basic "show an SVG on both platforms" is now a solved problem (Coil 3, Kamel, Landscapist, Compose Multiplatform resources). KMPMedia targets what those still don't do — full breakdown with versions + sources in docs/COMPARISON.md:
| Capability | KMPMedia | Coil 3 (coil-svg) |
Compose MP resources |
|---|---|---|---|
| Cross-platform SVG (Android + iOS) | ✅ | ✅ | |
| Rendered as live vectors (not a bitmap) | ✅ | ❌ (rasterized) | ❌ |
Runtime SVG animation (SMIL <animate>) |
✅ | ❌ | ❌ |
Wrap any static image/SVG → animated (OGAnimatedImage) |
✅ | ❌ | ❌ |
| Shape-crop image & video to any shape (circle/triangle/…) | ✅ | ❌ | ❌ |
Free-form polygon lasso — clip to any AI-/hand-drawn outline (OGPolygonShape) |
✅ | ❌ | ❌ |
Auto-cutout — turn a segmentation mask into a live lasso (autoCutoutPolygon) |
✅ | ❌ | ❌ |
Live camera clipped to any shape (OGCameraPreview) |
✅ | ❌ | ❌ |
Depth / layer management — fly over/under + depth-of-field blur + parallax (Modifier.ogDepth / ogParallax) |
✅ | ❌ | ❌ |
Data-defined drawing styles — JSON {op} pipeline → live boil / quantize / pixelate (OGStyleSpec) |
✅ | ❌ | ❌ |
| Interactive / draggable / gesture layers | ✅ | ❌ | ❌ |
Any graphic → a tappable button — shape-aware tap + press effects (Modifier.ogButton) |
✅ | ❌ | ❌ |
On-device compositor + export — layer timeline with keyframes → animated GIF & H.264 MP4 (OGComposition) |
✅ | ❌ | ❌ |
Motion & action FX — slash ribbons, forked lightning, speed-lines, glow / bloom (com.solidkey.painpoints.fx) |
✅ | ❌ | ❌ |
Data-defined particles & parametric shapes (OGParticleSpec / OGParametric) |
✅ | ❌ | ❌ |
Live colour looks + blend modes — data-defined grade (OGLookSpec / Modifier.ogLook) |
✅ | ❌ | ❌ |
Soft / gradient & multi-region masks (ogSoftClip / OGMultiRegionShape) |
✅ | ❌ | ❌ |
Audio-reactive vectors — bind live sound (bass/mid/treble/level + beat) to any vector (OGAudioAnalyzer / Modifier.ogAudioReactive) |
✅ | ❌ | ❌ |
Motion design — draw-on strokes, motion paths (glide + orient along a curve), staggered timing (OGMotionPath / Modifier.ogMotionPath / OGStagger) |
✅ | ❌ | ❌ |
Motion dynamics — natural "feel" filters: spring (lag / overshoot / settle), follow-through, squash & stretch, idle sway (OGSpring / OGFollowChain / OGSquash / OGSway) |
✅ | ❌ | ❌ |
Cloth — a 1-D Verlet fabric strip that hangs, holds its length & flutters in the wind: scarf / flag / banner / hair (OGClothStrip / OGClothSpec) |
✅ | ❌ | ❌ |
Cloth sheet — a 2-D Verlet sheet that drapes over & collides with capsule "poles": tarp / curtain / banner / flag (OGCloth / OGClothCollider) |
✅ | ❌ | ❌ |
Photo → an editable vector SCENE — gradients, texture, glow, scatter + make-it-live motion day→night (OGSceneSpec) |
✅ | ❌ | ❌ |
Clickable-joints mesh rig — bind joints & bend a dense mesh live (OGRig) |
✅ | ❌ | ❌ |
| Bundled image + audio + video suite | ✅ | ❌ | ❌ |
If all you need is a static SVG loaded from the network, a general image loader is the simpler choice. Reach for KMPMedia when you need media to move, respond, compose, or change at runtime.
Compared above against the two most-cited general options; the other KMP libraries are complementary and narrower — Kamel / Landscapist are image loaders (load + cache, static SVG, animated GIF), Compottie plays pre-authored Lottie / After-Effects animations (not runtime-authored media), Telephoto is a zoom/pan viewer. None provide the compositor + export, media-in-shape, runtime-editable vectors, FX or rig above. Verified 2026-10-11: Coil 3.6.3, Kamel 1.0.9, Landscapist 2.13.2, Compottie 2.3.3, Telephoto 0.19.0, Compose Multiplatform 1.12.1 — note Coil's animated GIF and video are Android-only. Full matrix + sources: docs/COMPARISON.md.
KMPMedia is built to be generated correctly by AI coding assistants, not just written by hand:
<svg> (including the animated SMIL subset) or a URL, and KMPMedia renders and animates it live on both platforms.OG…, and you describe what (a shape, an animation set, a cue at a timestamp, a depth) as data. Generated code compiles more often and hallucinates less surface.llms.txt API index and an AI coding guide with prompt→snippet examples.OGAiVector is a stable JSON wire format (a codec, not an AI SDK) for the library's vector primitives. It hands a model that contract and turns the reply, or a photo via a vision model (image → vector, v1.25.0), into a live polygon lasso, a multi-region scene cut-out, or an SVG node patch ("describe → shape / patch"); an optional centripetal-spline smoothing knob rounds a model's faceted points into a clean outline. No AI SDK or networking is pulled into the library, and it works with any model (or a segmentation network that emits vertices).OGStageAuthor turns a plain sentence into a playable OGStage movie, and OGOfflineStageAuthor does it with no model, no key and no network at all — it reads a description for mood / time-of-day / subject and assembles a real movie (day→night scene + atmosphere particles + an optional FX beat + a styled title) through the ordinary OGStages builders. That is the honest floor: the loop always works. Wire your own language model, cloud or fully on-device, into the same loop via OGStageModel / OGStageAuthoring.resilient(...) (bring your own key, the call stays in your app) for richer movies in the identical format. See docs/AI_AUTHORING.md.Today that makes KMPMedia AI-generatable and AI-drivable at runtime, including a full serializable scene-spec (OGSceneSpec, shipped in v1.25.0) that now also carries gradient fills, texture, glow and scatter and its own motion (a day→night scene timeline, v1.38–v1.40). An MCP server (an LLM emits validated data, not Kotlin, and previews it before writing code) remains on the roadmap to make it fully AI-ready.
KMPMedia ships with a full Compose Multiplatform demo app (Android + iOS) built directly against this library's source, so every screen is real, readable library code you can lift into your own app. It's the fastest way to see what "animated, interactive, runtime-editable" actually looks like.
The core moves, live. Crop a photo into any shape · re-frame a running video into any shape · bring a 100%-static SVG to life with Scale / Rotate / Fade / Slide. Each is one⏱️ A note on the GIFs' speed. GIF is a low-frame-rate, palette-limited format, and the shape/path morph clips here are additionally slowed (~2–3×) on purpose, so the shape-shift is easy to follow in a static browser — those morph captures are not the real speed. (The gameplay/feature clips play at real speed.) Running live, every screen animates at your display's refresh rate (~60fps) and is smooth, not steppy: each morph resamples its two endpoint outlines once (cached) and then only interpolates points per frame — no re-parse, no per-frame allocation. So the slowed morph captures read clear-and-slow on purpose; the actual app is fluid.
OGImageView / OGAVPlayer / animation-primitive call — the same Compose Multiplatform code on Android & iOS.
The capstone — one JSON → a whole movie, then exported — and a language model can write that JSON from one line. A single OGStage document stacks a day→night scene, drifting petals, a forked lightning bolt and styled vector-text titles on one timeline; one player plays it at 60fps and scrubs it, and it renders off-screen to a shareable GIF/MP4 on device — every layer kind baked in, identically on Android & iOS:
OGSceneSpec + OGParticles petals + an OGLightning bolt + styled vector text titles), authored as JSON — a language model can write the whole movie from one line via OGStages.stagePrompt() — round-tripped through the OGStages codec, played by OGStageView at 60fps (scrubbable), then rendered off-screen and encoded to these very GIFs (the iOS one rendered headless on the simulator through the same exportGif()). The generate → play → export loop no Lottie/Rive runtime does. Same code on Android & iOS.
…and now you can poke it — a movie you watch becomes one you touch (v1.44.0):
Tappable layers — the Rive gap, closed. AnyOGStageLayer can carry an onTap trigger, plain serializable data that round-trips through the codec (a model authors it too). A tap springs the layer to life — BOUNCE / SHAKE / SPIN are closed-form reactions reusing the dynamics pack's OGSpring; SEEK / PLAY_PAUSE drive the player clock. Live-player only and zero idle cost — a still, tappable stage waits for a finger before it moves.
And the flagship runtime-editable SVG — one .svg parsed once, then any node changed by its id at runtime, bound to Compose state:
.svg is parsed once; a slider only mutates an overrides map keyed by node id and the same drawing redraws live — the needle rotates and the arc + status dot recolour green→amber→red. No re-parse, identical on both platforms. See docs/RUNTIME_SVG.md.
…and the same mechanism morphs a path between two shapes — set a <path>'s pathDataTo and drive morphProgress 0→1 from any Compose animation:
<path> tweens between a star and a ring and back, forever. Both endpoint d strings are parsed once and cached, so each frame only interpolates points — no re-parse, no allocation churn, built for the "runs in a game at 60fps" bar. Same OGSVGView + overrides path as the gauge above, identical on both platforms. See docs/RUNTIME_SVG.md.
…and the newest — morph the clip mask itself (v1.10.0). The same idea applied to clipping: the outline that masks a running video (or GIF) animates circle → diamond → triangle → lasso while the media plays on:
Morph the clip itself, live — Android. OneOGMorphShape drives the mask; the video keeps playing underneath as the outline tweens circle → diamond → triangle → lasso (the dark stage shows the silhouette at the corners). The same Shape feeds OGImageView.clipShape (photos/GIFs), OGPlayerConfig.clipShape (video) and any Modifier.clip — no new surface API. Each endpoint outline is resampled once (cached by shape + size) and only the points are lerped per frame, so it holds 60fps. See docs/SHAPE_MORPH_CLIPS.md.
…and the mask can now also be soft and multi-region (v1.12.0). A clip no longer has to be one shape with a hard edge: Modifier.ogSoftClip(shape, feather) fades a photo/GIF out over a feathered band (a vignette) and Modifier.ogGradientMask(brush) fades it along any gradient (edge fade, spotlight) — both surfaced on OGImageView as softEdge / maskBrush; and OGMultiRegionShape combines several placed sub-shapes with a path op (union / intersect / difference / xor) so one clip can show two portholes, or a diamond with a circular bite. It's an ordinary Shape, so it drops into the same clipShape slot for photos, GIFs and video. Soft masks are a single offscreen BlendMode.DstIn pass (no blur, no API floor); multi-region combines outlines once per size — both hold 60fps. See docs/SOFT_MASKS.md.
OGImageView: softEdge feathers the clip edge into a vignette, maskBrush fades the photo along a gradient, and OGMultiRegionShape clips it to more than one region (two portholes, or a diamond with a circular bite). Soft masks are one offscreen BlendMode.DstIn pass — no blur, no API floor; multi-region is a path op computed once per size. The same code on both platforms. See docs/SOFT_MASKS.md.
…and the newest, the flagship — compose layered scenes and export them (v1.13.0). KMPMedia could put one piece of media in one shape; now it composes several layers on one timeline, animates them with keyframes, previews the result live at 60fps, and exports it to a single shareable animated GIF — all authored on device. OGComposition stacks image / solid layers, each with animatable x / y / scale / rotation / opacity tracks and any shape clip over a [startMs, endMs] window; OGCompositionView plays it on the Compose frame clock (or scrubs to any positionMs); and OGComposition.exportGif() renders every frame offscreen and encodes one looping GIF with a pure-Kotlin encoder — zero dependencies, identical bytes on Android and iOS. New in v1.14.0: the same composition also exports to a real H.264 MP4 with OGComposition.exportMp4(), driving the device's own video encoder (Android MediaCodec + MediaMuxer, iOS AVAssetWriter) — still with no third-party dependency, since the compositing and colour maths stay in shared, tested commonMain. No other KMP library does compose-and-export. See docs/COMPOSITOR.md.
OGComposition timeline, each with keyframed x / y / scale / rotation / opacity and a shape clip, previewed at 60fps by OGCompositionView. In the app, Export renders the timeline and encodes it to a single looping animated GIF with the pure-Kotlin OGGifEncoder (median-cut palette + Floyd–Steinberg dithering + LZW, zero deps, so gradients stay band-free) — then plays that exact GIF back through the platform's own decoder, proving it's a real, shareable file. The same code on both platforms. See docs/COMPOSITOR.md.
Same composition, exported to a real H.264 MP4 — Android (left) & iOS (right). The identical keyframed scene, this time rendered by OGComposition.exportMp4() through the device's own video encoder (Android MediaCodec + MediaMuxer, iOS AVAssetWriter) — no third-party dependency. On Android the exported file is played straight back in OGAVPlayer, proving it's a real, shareable .mp4; the iOS capture shows the same export producing a valid H.264 .mp4 (standard High profile, 288×288 @ 20fps). The compositing and colour maths (RGB→YUV / BGRA) live in shared, tested commonMain; only the thin encoder is per-platform. New in v1.14.0. See docs/COMPOSITOR.md.
…and that clipped media is now interactive — grab, drag, pinch-zoom, twist-rotate, fling and spring back (v1.15.0). Modifier.ogInteractive(state, hitArea) makes any composable gesture-driven: it applies the pan / zoom / rotation through a single graphicsLayer (a GPU-layer transform, no recomposition, 60fps) and drives it from a custom awaitEachGesture loop, with a momentum fling on release and a spring settle back inside its bounds. Pass an OGHitArea and the touch is shape-aware — a drag only starts inside the clip's silhouette, so the transparent corners of a triangle / circle / lasso fall through instead of grabbing it. Pan limits are a fraction of the content size, so one config works at any resolution; the hit-test and clamp maths are unit-tested on JVM + iOS. No other KMP media library makes a shape-clipped layer physically interactive. See docs/INTERACTIVE.md.
Modifier.ogInteractive, with a momentum fling and a bouncy spring settle. OGHitArea makes the touch shape-aware (grab the silhouette, not its bounding box). The motion here is driven by a scripted loop so both platforms show the full pan / zoom / rotate / spring identically (the capture tooling can't inject multi-touch); in the app it's your fingers. The transform rides one GPU graphicsLayer, so it holds 60fps; the hit-test and clamp maths are unit-tested on JVM + iOS. New in v1.15.0. See docs/INTERACTIVE.md.
…and the newest, most-animated additions now have GIFs — captured straight from the demo (Android shown; almost all are pure shared commonMain maths drawn by the same Skia engine, so the iOS frame is identical — the two platform-backed exceptions, bloom's blur and styled text's base font, are noted below; run the demo to see any of them live on either OS):
ogBloom's halo is a real platform gaussian blur (Android RenderEffect 31+ / iOS Skia), so it's visually equivalent rather than pixel-identical.
✍️ Motion design — draw-on, motion paths & stagger. A stroke that draws itself on by arc length, an element gliding + turning along a curve, and staggered group timing — all normalized 0..1, pure arc-length maths, 60fps, same code on Android & iOS. See docs/MOTION.md.
🌀 Motion dynamics — robotic → alive. The same moving target chased by a robotic twin (constant speed, stops dead) and one fed through the dynamics layer: spring lag / overshoot / settle, squash & stretch, a follow-through comet tail and idle sway, switchable between five feel presets. Pure closed-form maths (so the compositor can scrub + export it), 60fps, same code on Android & iOS. See docs/MOTION.md.
🎬 Composing it all — a walk cycle at sunset. Not one primitive but many at once: the 1.35.0 dynamics driving a gait (OGSpring follow-through on the knees/elbows + OGSquash footfall), a 1-D OGClothStrip scarf and a 2-D OGCloth flag (v1.37.0) both rippling in the wind, OGSway head drift, OGSpeedLines, OGParticles petals, and a sun that sets as an animated ogLook grade warms then cools the whole frame day → dusk → night — a photo head clipped to a lasso riding along. Every effect here is a shipped library primitive; the rig + sunset choreography are ~demo code. See the demo's 🎬 Scene in motion screen.
🪄 Auto-cutout → live lasso. The background is removed and the silhouette traced to a polygon lasso that drops into the same clipShape slot (shown on a synthetic subject so it reproduces without a photo). A still — the result itself doesn't animate.
Here's the full rundown of this wave (📷 live camera is the one effect not shown here — it needs a real device's camera, so it stays described):
OGSegmenter seam (plug in ML Kit / Vision / a cloud model) plus a cross-platform mask → lasso tracer (flood-fill → Moore-neighbour trace → Douglas–Peucker simplify) whose output drops straight into the same clipShape slot. No ML model is bundled. See docs/AUTO_CUTOUT.md.OGCameraPreview(shape, facing) masks the live camera feed to any shape (a built-in OGShapeType, an OGPolygonShape lasso, CircleShape, …): the AR-sticker primitive, built on the platform camera APIs (Camera2 / AVFoundation) with no third-party dependency. See docs/CAMERA.md.OGStyleSpec is a JSON pipeline of {op, params} that OGStyles.decode() compiles into a live OGStyle applied every frame — boil (a living, hand-drawn line), quantize (a stepped, stop-motion line) and pixelate (a low-res mosaic fill). A designer or a language model can author and share a .style pack with no code and no rebuild, mirroring how OGAiVector turns a model's JSON into shapes. Zero new dependency; the demo's 🖊️ Boiling lines screen (with a live "Style from JSON" editor) dogfoods it. See docs/STYLES.md.Modifier.ogButton(hitArea, pressEffect) { onClick() }, makes any graphic (a photo, an SVG, a lasso-cut cut-out, a Canvas drawing) a real, accessible button. It's the tap twin of Modifier.ogInteractive — both reuse OGHitArea, so a tap only counts inside the silhouette and the transparent corners fall through. Pick a press effect — Scale, Dim, Brutalist (a pop-art hard-shadow push-in it draws itself) or None — add an optional long-press, and it's done. Zero dependency; the demo's 🔘 Any graphic → a button screen dogfoods it. See docs/BUTTON.md.OGParticleSpec (rate, gravity, colour-over-life, shape) compiled once into a OGParticleView (a struct-of-arrays sim, no per-frame allocation, deterministic from a seed), and parametric outlines (star / gear / flower / superellipse / blob) wrapped in an OGPolygonShape clipShape. Author-as-JSON, 60fps, same on Android & iOS. See docs/PARTICLES.md · docs/PARAMETRIC_SHAPES.md.OGStyledText vectorizes a string once and runs each glyph outline through a drawing style (roughen / boil / wave / …) every frame, so the letters are hand-inked and alive — a title card or a word-game tile. See docs/STYLES.md.com.solidkey.painpoints.fx family for composing stylised action scenes: OGSlashView (a breathing-slash ribbon — water / flame / thunder by data), OGLightningView (forked, fractal lightning), OGSpeedLinesView (manga impact lines), elemental particle presets (petals / embers / droplets / leaves), and Modifier.ogGlow / Modifier.ogAfterImage. Each effect is serializable data; all pure maths, 60fps, frame-identical on Android & iOS — no shaders, no new dependency. See docs/SLASH.md.Modifier.ogBloom(radius, intensity, color?) redraws a composable through a real platform gaussian blur (Android RenderEffect / iOS Skia), blended additively, for a soft luminous halo — the cinematic cousin of the zero-dependency, frame-identical ogGlow. Opt-in: the blur needs Android 31+ (it no-ops to a crisp draw below) and isn't pixel-identical across platforms. This release also repairs a 1.30.0 bug that silently no-op'd ogGlow and ogAfterImage, so the composite FX modifiers now render correctly. See docs/SLASH.md.OGLookSpec (brightness / contrast / saturation / temperature / tint / hue) compiles once into a single ColorFilter you drop on any graphic with Modifier.ogLook, plus a per-layer grade + blend modes on the compositor. One GPU colour op, zero per-frame allocation, 60fps, pixel-identical on Android & iOS; a look serializes to a .look JSON a model can author (like OGAiVector / OGStyles). See docs/LOOKS.md.OGFft) folds PCM into bass / mid / treble / level + a beat flag (OGAudioAnalyzer), smoothed with attack/release, and Modifier.ogAudioReactive binds a band to any composable (or read it straight into a spec). The capture is a pluggable OGAudioSource — a built-in deterministic OGSyntheticAudioSource runs it with no mic, and you feed your own mic/music tap on device, so no platform audio or permission enters the library. Zero-dep, 60fps, same code on Android & iOS. See docs/AUDIO_REACTIVE.md.com.solidkey.painpoints.motion: draw-on (a stroke or logo that draws itself on by arc length, OGDrawOnStroke), motion paths (send any element gliding along a curve at constant speed and turning to face its travel, Modifier.ogMotionPath), and stagger (one timeline → per-element offset timing so a group cascades / waves / pops, OGStagger). The path + stagger are plain serializable data (OGMotions), the core is pure arc-length maths (O(log n) per lookup, centripetal smoothing, no recomposition), 60fps, same code on Android & iOS. See docs/MOTION.md.com.solidkey.painpoints.motion: an analytic second-order spring (OGSpring — lag, overshoot, settle; closed-form so the compositor can scrub + export it, which androidx…spring() can't), follow-through (OGFollowChain, a trailing chain for hair / cape / limbs), volume-preserving squash & stretch (OGSquash) and organic idle sway (OGSway). The whole feel is data — an OGDynamicsSpec with five presets (bouncy / heavy / snappy / gentle / stiff) a model can author from a sentence (OGDynamics.dynamicsPrompt), the same pattern as OGLooks / OGMotions / OGStyles. Pure maths, zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.com.solidkey.painpoints.motion: OGClothStrip, a one-dimensional Verlet cloth strip pinned at the head that hangs under gravity, stays inextensible (four position-based distance-constraint passes, so it swings but never stretches) and flutters in a prevailing wind plus seeded OGFxNoise gusts — a scarf, flag, banner or lock of hair. A fixed internal substep makes it stable and byte-for-byte deterministic at any frame rate, and OGCloths.sampleAt(spec, t) re-simulates to a time so the compositor can scrub + export it. The strip is plain serializable data (OGClothSpec) with four ready fabrics (scarf / flag / banner / hair) a model can author (OGCloths.clothPrompt). Reach for it when something should hang and flutter; reach for OGFollowChain when something should stretch and trail. Zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.OGCloth, a 2-D planar Verlet sheet (a cols × rows grid of masses held by structural + shear springs, inextensible) that does the thing the 1-D strip can't — it drapes over and collides with OGClothCollider capsule "poles" (a line + radius; a circle is a zero-length capsule), so you can throw a tarp over a scaffold bar or fly a flag off a pole. Same fixed-substep determinism (byte-for-byte identical on Android & iOS), four pin modes (NONE / TOP_EDGE / TOP_CORNERS / LEFT_EDGE), and the same data-defined, AI-authorable codec (OGClothMeshSpec + OGCloths.meshPreset / clothMeshPrompt, presets tarp / curtain / banner-wall / sail) with OGCloths.sampleAt for deterministic scrub + export. Deliberately a planar media primitive — no 3-D cloth, self-collision, tearing or solver API. Zero dependency, 60fps, same code on Android & iOS. See docs/MOTION.md.OGSceneSpec "photo → vector scene" format grew up. v1.38.0 adds per-shape gradient fills (OKLab-resampled, no banding) plus a scene background + colour grade; v1.39.0 adds richer stills — a procedural texture (fur / grass / grain), an additive glow halo, and a seeded scatter that stamps one shape into a whole field (trees / tufts); v1.40.0 makes it live — a region declares its own looping motion (translate / scale / rotate / fade) and the scene swings a colour grade + sky over time (day → dusk → night), all pure and t-addressable so the compositor can scrub + export it. Backward-compatible, pure codec, 60fps, same code on Android & iOS.OGRig binds a handful of joints to the mesh once (linear-blend skinning, partition-of-unity weights), then a drag warps the whole mesh around the joint, re-skinned every frame — the one case the rasterise-once / cache path can't cover. ~1.3 ms to skin 20k vertices; deformInto is zero-allocation, nearestJoint is the touch hit-test, and a released joint springs home with OGSpring. Pure maths, zero dependency, 60fps, frame-identical on Android & iOS.OGStage stacks solid / scene / particles / slash / lightning / speedlines / styled-text layers, each transformed by the compositor's keyframe tracks and graded by a look. One OGStageView plays it at 60fps and scrubs it; OGStage.exportGif() / exportMp4() render it off-screen with every layer kind baked in (the iOS README clip was rendered headless on the simulator through that very path). A language model authors the whole movie from one line via OGStages.stagePrompt() — the codec tolerates real model output (bare-number tracks, case-insensitive enums, markdown fences), and three model-written movies (STORM / HANAMI / RUSH) ship in the demo and are pinned as regression tests. v1.44.0 makes it interactive: any layer can carry an onTap trigger — tap it and it springs to life (BOUNCE / SHAKE / SPIN via the dynamics OGSpring, or SEEK / PLAY_PAUSE), the Rive-style interactivity the CMP players led on, as plain model-authorable data. v1.45.0 closes the describe→movie loop: OGStageAuthor turns a plain sentence into a playable stage, and OGOfflineStageAuthor does it with no model, no key and no network (a deterministic director that reads the words for mood / subject) — so the loop always works; wire your own model (cloud or on-device) into the same loop via OGStageModel / OGStageAuthoring.resilient(...) for richer movies. Thin orchestration, no new per-frame cost, same code on Android & iOS. See docs/STAGE.md and docs/AI_AUTHORING.md.(📷 Live camera is the one effect without a GIF here — it needs a real device's camera (there's nothing to film on a headless emulator), so run the demo on a device to see it live.)
And a whole mini-game built from those same primitives:
UFO Dodge, live — Android (left) & iOS (right). The same Compose Multiplatform code on both: a real looping video clipped into the backdrop, SVG sprites animated by the library, and the video's own timeline firing an in-game meteor-storm cue.And animated GIFs — one OGImageView pointed at a .gif, playing on both platforms (and clipped into shapes just like a still photo):
OGImageView pointed at a .gif URL — it auto-detects and loops the frames (Android AnimatedImageDrawable, iOS Skia Codec). The same shape clip that crops a still photo animates the moving frames inside a circle / diamond too. See docs/GIF.md.
Android — “Add Your Head to a Body”, then UFO Dodge.
iOS — the same Compose Multiplatform code on an iPhone simulator: “Add Your Head to a Body” (your photo cut out by the OGPolygonShape head-lasso, exactly as on Android), the home menu, then UFO Dodge (black-hole warp + a meteor-storm cue).
What's inside
<animate> tags) and bring it alive with the animation primitives: Scale / Rotate / Fade / Slide, combined live.id at runtime — recolour, rotate, move, reshape its path, or morph it smoothly between two shapes — bound to Compose state (OGSvgNodeOverride). A live gauge whose needle and zones follow a slider, and a star that tweens into a ring at 60fps, no re-parse. "SVG as a live template", identical on Android & iOS.OGPolygonShape) clips out just the head — no external editor — pinned at the neck joint you set (size + tilt); or fall back to a circle / triangle / diamond. Then tap Wave / Walk / Jumping jacks / Dance and the whole body animates, your head riding along. The head is one OGImageView clip; the rig and every dynamic are plain Compose, identical on Android & iOS.OGImageView at a .gif and it plays: looping frames on Android (AnimatedImageDrawable) and iOS (Skia Codec), the same code. The shape clip that crops a photo animates the moving frames inside a circle / diamond too.OGAVPlayer (ExoPlayer on Android, AVPlayer on iOS), re-framed live into any shape, with transport controls, loop and load-any-URL.OGMorphShape. Clipping moving media to a morphing outline at 60fps, identical on Android & iOS.softEdge), fade it along a gradient (maskBrush), or clip it to more than one region at once — two portholes, a diamond with a circular bite — with OGMultiRegionShape. Soft edges via one offscreen DstIn pass; multi-region via a path op computed once. Same code on Android & iOS.Modifier.ogInteractive adds the transform + momentum + spring; OGHitArea means only touches inside the actual silhouette grab it, not its bounding box. Zero-dep, 60fps, same code on Android & iOS.OGSegmenter seam (ML Kit / Vision / cloud) plus a zero-dependency mask → lasso tracer that feeds the same clipShape slot. No bundled ML model.OGCameraPreview(shape, facing) masks the live camera feed to any shape (built-in / lasso / CircleShape), front or back: the AR-sticker primitive, on Camera2 / AVFoundation with no third-party dependency.OGStyleSpec JSON pipeline of {op, params} (boil / quantize / pixelate) that OGStyles.decode() compiles into a live OGStyle applied every frame. Author and share .style packs with no code; the 🖊️ Boiling lines screen has a live "Style from JSON" editor.Modifier.ogButton: an SVG star, a lasso-cut photo head and a diamond, each with a different press effect (Scale / Dim / Brutalist) and shape-aware taps — tap the head and it fires; tap a transparent corner of the same photo and it falls through. The tap twin of ogInteractive, zero-dep, same code on Android & iOS.OGParticleView with a live JSON editor, and parametric outlines (star / gear / flower / squircle / blob) spun on the GPU — both author-as-JSON.OGSlashView water / flame / thunder slashes with live Width / Glow / Breathing sliders, a JSON editor, and composed droplet / ember / spark particles.OGLightningView lightning, manga OGSpeedLinesView impact lines, elemental particle presets, the ogGlow / ogAfterImage modifiers, and a composed "action beat" assembling several on one stage.onError fires cleanly; and load-timing / many-layer stress benchmarks with live numbers.Run it — the demo lives in its own companion repo and consumes this library via includeBuild:
./gradlew :androidApp:assembleDebug, install the APK, or open it in Android Studio. Prebuilt APKs are on the demo repo's Releases page.minSdk per catalog, compiled against JDK 17)iosArm64, iosSimulatorArm64)ℹ️ There are no JVM-desktop, web, or Linux targets in this build.
✅ Available on Maven Central. Add the coordinate below and you're set. If you'd rather build against the library source (or need an unreleased change), see
includeBuildor GitHub Packages below.
KMPMedia is a Compose Multiplatform library. In your settings.gradle.kts, make sure the Compose dev repo is available alongside the usual repositories:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}Then add the dependency to your shared module's commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("se.solidkey:kmpmedia-lib:1.41.0")
}
}
}Add the GitHub Packages repository (requires a GitHub token with read:packages):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven("https://maven.pkg.github.com/SolidKeyAB/kmpmedia") {
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_TOKEN")
}
}
}
}includeBuild)To hack on the library itself or consume an unreleased change, depend on a local checkout instead of Maven Central — this is exactly how the demo app uses it:
// settings.gradle.kts of the consuming project
includeBuild("../KMPMedia") // path to your local clone// consuming module's build.gradle.kts
commonMain.dependencies {
implementation("se.solidkey:kmpmedia-lib")
}import com.solidkey.painpoints.image.svg.OGSVGView
import com.solidkey.painpoints.image.loading.OGSvgUrlType
OGSVGView(
source = OGSvgUrlType("https://example.com/logo.svg"),
width = 240f,
height = 240f,
onError = { message -> /* handle load/parse errors */ },
)Sources can also be local files or bundled resources:
OGSvgFileType("/path/to/icon.svg")
OGSvgResourceFileType("icon.svg")import com.solidkey.painpoints.image.svg.animation.OGSVGAnimationPlayer
OGSVGAnimationPlayer(
source = OGSvgResourceFileType("spinner.svg"),
width = 120f,
height = 120f,
isPlaying = true,
loop = true,
onError = { /* ... */ },
)OGAnimatedImage wraps any source — a photo, or even a plain SVG with no <animate> tags — and animates it at runtime. The source file is never modified; the motion comes entirely from the wrapper.
import com.solidkey.painpoints.image.animating.OGAnimatedImage
import com.solidkey.painpoints.image.animating.OGAnimationType
import com.solidkey.painpoints.image.loading.OGImageUrlType
OGAnimatedImage(
source = OGImageUrlType("https://example.com/logo.png"),
animations = setOf(OGAnimationType.SCALE, OGAnimationType.ROTATE), // SCALE / ROTATE / FADE / TRANSLATE
durationMillis = 1200,
intensity = 1f,
onError = { /* ... */ },
)import com.solidkey.painpoints.image.OGImageView
import com.solidkey.painpoints.image.loading.OGImageUrlType
OGImageView(
source = OGImageUrlType("https://example.com/photo.jpg"),
draggable = true,
onEventTriggered = { event, id -> /* layer interaction events */ },
onError = { /* ... */ },
)Crop the photo into a shape (circle, triangle, diamond, …) with a GPU clip — drawn once, no bitmap cost:
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.solidkey.painpoints.shape.OGShapeType
OGImageView(
source = OGImageUrlType("https://example.com/portrait.jpg"),
modifier = Modifier.size(220.dp),
displayShape = OGShapeType.CIRCLE, // CIRCLE / TRIANGLE_UP / TRIANGLE_DOWN / DIAMOND / SQUARE / RECTANGLE
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter, // which region stays visible when the photo is over-scaled
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)The same shape-crop applies to video — a built-in shape via
OGAVPlayer(config = OGPlayerConfig(displayShape = OGShapeType.CIRCLE)), or a free-form lasso viaOGPlayerConfig(clipShape = OGPolygonShape.of(...))(the sameclipShapeoverride as images).
Need to keep only an arbitrary region — a head, a logo, a hand-drawn area? Clip to a free-form polygon lasso instead of a built-in shape. The outline is just a list of normalized 0..1 vertices joined by line segments, so an AI/segmentation model or an on-image finger-draw can produce it directly — no external editor:
import com.solidkey.painpoints.shape.OGPolygonShape
OGImageView(
source = OGImageResourceFileType("portrait", OGImageFormat.JPEG),
modifier = Modifier.size(220.dp),
contentScale = ContentScale.Crop,
clipShape = OGPolygonShape.of( // vertices in 0..1 space (top-left origin), any count ≥ 3
0.48f to 0.02f, 0.86f to 0.22f, 0.84f to 0.54f,
0.48f to 0.83f, 0.16f to 0.53f, 0.15f to 0.22f,
), // ← clips to exactly this outline; see docs/POLYGON_SHAPE.md
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)Point OGImageView at a .gif and it plays — no extra API. The frames animate on both platforms (previously the image path only ever showed the first frame). Everything else about OGImageView still applies: contentScale, alignment, and shape-crop / lasso clipping all work on the moving image.
import com.solidkey.painpoints.image.OGImageView
import com.solidkey.painpoints.image.loading.OGImageUrlType
// A .gif URL (or file path) auto-detects and loops.
OGImageView(
source = OGImageUrlType("https://example.com/loading.gif"),
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)Want direct control over looping / speed, or to feed the animation into your own Image? Use the underlying painter:
import androidx.compose.foundation.Image
import com.solidkey.painpoints.image.gif.rememberOGAnimatedPainter
import com.solidkey.painpoints.source.OGSource
val painter = rememberOGAnimatedPainter(
source = OGSource.Url("https://example.com/loading.gif"),
loop = true,
speed = 1.5f, // iOS honours speed; Android plays at native rate
)
painter?.let { Image(painter = it, contentDescription = null) }How it works — Android decodes into the platform's self-animating
AnimatedImageDrawable(API 28+; the static first frame on API 24–27). iOS decodes every frame with Skia'sCodecand cycles them honouring per-frame delays andspeed. See docs/GIF.md.
See docs/POLYGON_SHAPE.md for the full API and how the demo's "Add Your Head to a Body" uses it to cut out a head.
import com.solidkey.painpoints.video.playing.OGAVPlayer
import com.solidkey.painpoints.video.playing.OGAVPlayerAction
import com.solidkey.painpoints.video.loading.OGVideoUrlType
val result = OGAVPlayer(
action = OGAVPlayerAction.PLAY, // PLAY / PAUSE / STOP
source = OGVideoUrlType("https://example.com/clip.mp4"),
onError = { error -> /* DAVPlayerError */ },
)New in 1.1.0. Give any content a depth (0f = far/behind, 1f = near/front) relative to a focal plane, and KMPMedia derives all three depth effects from that one number: z-order (fly over/under), a depth-of-field blur + dim that grows with distance from focus, and an optional parallax scale.
The easy path — a field of objects that share one focal plane; each child just states its own depth:
import com.solidkey.painpoints.depth.OGDepthField
import com.solidkey.painpoints.depth.OGDepthObject
import com.solidkey.painpoints.depth.OGDepthConfig
OGDepthField(focalDepth = 0.5f) { // 0.5 is the plane in sharp focus
OGDepthObject(depth = 0.1f) { Backdrop() } // far → behind + blurred
OGDepthObject(depth = 0.5f) { Subject() } // in focus → crisp
OGDepthObject(depth = 0.9f, config = OGDepthConfig.Video) {
OGAVPlayer(...) // near, in front — Video preset: dim, no blur
}
}Or drop it onto any single composable:
import com.solidkey.painpoints.depth.ogDepth
OGImageView(..., modifier = Modifier.ogDepth(depth = 0.2f, focalDepth = 0.5f))Tune it with OGDepthConfig(maxBlur, minAlpha, dimFalloff, blurContent, depthScale); use OGDepthConfig.Video for video/native surfaces (dim + z-order, no blur — a RenderEffect blur over a TextureView/AVPlayerLayer is unreliable). In focus with defaults it applies only zIndex (no extra layer). Full perf + compatibility analysis in docs/DEPTH_LAYER.md.
New in 1.11.0. KMPMedia's vector primitives are just data, so a language model can produce them. OGAiVector is a provider-agnostic JSON interop layer: it hands a model the exact contract with polygonPrompt(...) / svgPatchPrompt(...), and turns the reply back into a live clip shape or SVG patch — tolerant of the code fences and prose models add. No AI SDK or networking is pulled into the library; you own the model and the call.
import com.solidkey.painpoints.ai.OGAiVector
// describe → clip region: the model's reply becomes an OGPolygonShape for any clipShape
val prompt = OGAiVector.polygonPrompt("the outline of a five-pointed star")
val star = OGAiVector.decodePolygonOrNull(myLlm.complete(prompt)) // your model, your call
// describe → SVG patch: constrain the model to the ids that exist, apply the result live
val patch = OGAiVector.svgPatchPrompt("point the needle to 80% and turn the arc amber",
nodeIds = listOf("needle", "arc"))
OGSVGView(source = OGSvgResourceFileType("gauge.svg"), width = 240f, height = 240f,
overrides = OGAiVector.decodeSvgPatchOrNull(myLlm.complete(patch)) ?: emptyMap())
// image → vector (new in 1.25.0): hand a VISION model a photo/frame, get editable vectors back
val imgPrompt = OGAiVector.imageToVectorPrompt(hint = "trace the person", imageInfo = OGImageInfo.of(photo))
val cutout = OGAiVector.decodePolygonOrNull(myVisionLlm.complete(imgPrompt, photo.toBase64Png()))encodePolygon / encodeSvgPatch / encodeScene go the other way (persist a lasso/scene, seed a prompt with the current state). New in 1.25.0: imageToVectorPrompt(...) + decodeScene(...) turn a photo (or a live frame) into editable vector shapes via any vision model — you attach the image with ImageBitmap.toBase64Png(), and the library still makes no network call. All of it runs at generate/patch time, never per frame. See docs/AI_HOOKS.md.
New in 1.23.0. A drawing style is just data — a JSON pipeline of {op, params}. OGStyles.decode(...) compiles it once into a live OGStyle; apply it each frame with a rising timeMs. The ops are boil (the living hand-drawn line), quantize (a stepped line) and pixelate (a low-res sprite fill); a designer or a model can author and share a .style pack with no code.
import com.solidkey.painpoints.style.OGStyles
val style = OGStyles.decode(
"""{"name":"pixel sprite","ops":[
{"op":"boil","amplitude":0.03,"boilFps":10},
{"op":"pixelate","resolution":20}
]}""",
)
val frame = style.apply(outline, timeMs) // outline: List<OGPoint> in 0..1 space
if (frame.isPixelated) { /* draw frame.pixelSize squares at frame.pixels */ }
else { /* stroke or fill frame.outline */ }Or give a media clip a living, hand-cut edge — a boiled OGPolygonShape drops into any clipShape slot, recreated each frame from a rising time:
import com.solidkey.painpoints.style.OGBoil
import com.solidkey.painpoints.style.boiled
OGImageView(
source = OGImageUrlType("https://example.com/photo.jpg"),
clipShape = OGPolygonShape(outline).boiled(OGBoil(amplitude = 0.02f), timeMs),
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)OGStyles.stylePrompt("a nervous pencil sketch") hands any model the exact JSON contract, so a style can be AI-authored too. See docs/STYLES.md.
New in 1.24.0. Modifier.ogButton makes any graphic a real, accessible, shape-aware button — no UI component, no theme system. Pass an OGHitArea matching the clip and only taps inside the silhouette count; the transparent corners fall through. Pick a press effect and you're done.
import com.solidkey.painpoints.gesture.ogButton
import com.solidkey.painpoints.gesture.OGHitArea
import com.solidkey.painpoints.gesture.OGPressEffect
OGImageView(
source = photo,
clipShape = headLasso, // the visible cut-out
modifier = Modifier
.size(160.dp)
.ogButton(
hitArea = OGHitArea.polygon(headLasso), // only the head is tappable
pressEffect = OGPressEffect.Brutalist(), // pop-art hard-shadow push-in
onLongClick = { showOptions() },
) { open() },
onEventTriggered = { _, _ -> },
onError = { /* ... */ },
)It's the tap twin of Modifier.ogInteractive (the drag half) — both reuse OGHitArea. Other press effects: OGPressEffect.Scale() (a squeeze), OGPressEffect.Dim() (a fade), OGPressEffect.None. See docs/BUTTON.md.
The SVG engine is a pure-Kotlin parser + renderer. Honest status:
Supported
M L H V C Q Z and arcs (A, approximated with béziers)rect (incl. rx/ry), circle, ellipse, line, polygon, polyline
<g>, <use>, <symbol>
translate, scale, rotate, skewX/Y, matrix
<animate>) and interactive/draggable layersPartial / not yet supported
<clipPath>, <mask>
<text> / <tspan>
<image>Anything not listed above as working should be treated as unsupported for now.
Pre-1.0 in spirit — the SVG animation/interaction layer is the actively developed core; the audio/video/image pieces are functional wrappers over platform players. See the CHANGELOG for release history.
MIT © SolidKey AB