
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, and video, 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, Compose Multiplatform resources). KMPMedia targets what those still don't do:
| 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) |
✅ | ❌ | ❌ |
Depth / layer management — fly over/under + depth-of-field blur (Modifier.ogDepth) |
✅ | ❌ | ❌ |
| Interactive / draggable / gesture layers | ✅ | ❌ | ❌ |
| 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 the SVG to move, respond, or change at runtime.
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.Today that makes KMPMedia AI-generatable. A serializable scene-spec + MCP server (an LLM emits validated data, not Kotlin, and previews it before writing code) are 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.
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 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.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.10.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.
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, and video, 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, Compose Multiplatform resources). KMPMedia targets what those still don't do:
| 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) |
✅ | ❌ | ❌ |
Depth / layer management — fly over/under + depth-of-field blur (Modifier.ogDepth) |
✅ | ❌ | ❌ |
| Interactive / draggable / gesture layers | ✅ | ❌ | ❌ |
| 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 the SVG to move, respond, or change at runtime.
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.Today that makes KMPMedia AI-generatable. A serializable scene-spec + MCP server (an LLM emits validated data, not Kotlin, and previews it before writing code) are 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.
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 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.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.10.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.
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