How to Make a Video Editor App
How to Vibe Code a Native Android Video Editor App
Stay in your creative flow. Direct AI models with high-level editing intent, combine a Jetpack Compose multi-track timeline scrubber with Jetpack Media3 ExoPlayer & Transformer, and ship a mobile video studio APK in under 30 minutes.
Core Stack
Compose + Media3
Export Engine
Media3 Transformer
AI Partner
Claude / Gemini
Target Time
25β35 Mins
The Vibe Coding Mobile Philosophy
Historically, Android video editing meant wrestling JNI FFmpeg C wrappers, manual SurfaceTexture syncing, and multi-megabyte APK bloat. Today, Google's Jetpack Media3 Transformer handles hardware-accelerated trimming, concatenation, LUT color grading, and text overlays nativelyβand Vibe coding lets you orchestrate the entire multi-track timeline UI and Media3 pipeline through conversational prompts.
Old-School Mobile Stress
- Cross-compiling FFmpeg NDK libraries across arm64-v8a and x86_64.
- Building custom XML HorizontalScrollViews for clip trim handles.
- Audio and video tracks drifting out of sync during MP4 muxing.
- Software encoding draining battery and taking 5 minutes to export a 15s clip.
The Vibe Coder Way
- Prompt AI for an interactive Compose multi-track timeline with playhead scrubbing.
- Adjust clip split points, playback speed (
0.5xβ2.0x), and text overlays visually. - Use Jetpack Media3
ClippingConfigurationandTransformerfor GPU export. - Switch aspect ratios (
9:16Reels,16:9Cinema,1:1Square) with 1 tap.
The 4-Step Blueprint
From an empty Android Studio project to a multi-track non-linear video editor.
Empty Compose Setup
Start a new Empty Activity (Compose) project in Android Studio named VibeCut. You can prototype the entire timeline and preview monitor in pure Compose first.
Configure Your Timeline
Pick your studio aesthetic, timeline engine, and effects toolkit in the configurator below, then paste the prompt into Claude or Gemini.
clips.sumOf { it.durationSec / it.speed } so speed changes update the scrubber immediately.
Single-File Drop-In
Paste the generated code into MainActivity.kt to test playhead scrubbing, clip splitting, speed ramps, LUT presets, and text overlays.
clip.durationSec so longer clips look wider visually.
Wire Media3 Transformer
Use the Prompt Vault below to attach real MP4 video picking and hardware-accelerated H.264 rendering via Jetpack Media3.
Vibe Prompt Configurator
Customize your editing suite aesthetic, timeline engine, and effects pipeline. Copy the generated prompt directly into Claude, Cursor, or Gemini.
Essential Elements of a Video Editor App
Mobile creators expect desktop-grade non-linear editing in the palm of their hand. Make sure your video editor covers these 6 core capabilities:
-
Trim, Split & Merge Video Clips
Allow users to scrub a timeline playhead, split clips into segments, trim unwanted footage, and stitch multiple clips into a seamless story.
-
Text Titles & Audio Track Overlays
Support timed text captions, lower-third titles, and background music or voiceover tracks with independent volume controls.
-
Visual Filters & Playback Speed
Provide 1-tap color grading LUTs (Teal & Orange, Cyber Neon, Vintage) alongside speed controls from
0.5xslow-mo to2.0xfast-forward. -
HD / 4K Export & Social Sharing
Let creators export their finished sequence in 1080p or 4K with a clear rendering progress bar and 1-tap sharing to social platforms.
-
Broad Video Codec Compatibility
Leverage Jetpack Media3 so your app smoothly decodes and muxes standard H.264, HEVC (H.265), and AAC MP4 containers without crashes.
-
Camera Capture & Aspect Ratios
Let users import clips or shoot new footage directly, and switch aspect ratios between
9:16Reels,16:9YouTube, and1:1Square.
The "Golden" Single-File Compose Template
Replace the contents of your MainActivity.kt with this to run a working Non-Linear Video Editor Studio right now.
package com.example.vibevideoeditor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
data class TimelineClip(
val id: Int,
val title: String,
val durationSec: Float,
val speed: Float = 1.0f,
val filterName: String = "Natural",
val color: Color
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF06B6D4),
background = Color(0xFF080C14),
surface = Color(0xFF131B2E)
)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
VideoEditorStudioScreen()
}
}
}
}
}
@Composable
fun VideoEditorStudioScreen() {
val clips = remember {
mutableStateListOf(
TimelineClip(1, "Intro_Drone.mp4", 4.0f, 1.0f, "Teal & Orange", Color(0xFF6366F1)),
TimelineClip(2, "B_Roll_Cafe.mp4", 6.0f, 1.0f, "Natural", Color(0xFFEC4899)),
TimelineClip(3, "Outro_Street.mp4", 5.0f, 1.5f, "Cyber Neon", Color(0xFF10B981))
)
}
var selectedClipId by remember { mutableIntStateOf(1) }
var isPlaying by remember { mutableStateOf(false) }
var playheadSec by remember { mutableFloatStateOf(0f) }
var overlayTitle by remember { mutableStateOf("VLOG EP. 04 // TOKYO") }
var aspectRatio by remember { mutableStateOf("9:16") }
var showExportModal by remember { mutableStateOf(false) }
val totalDuration = clips.sumOf { (it.durationSec / it.speed).toDouble() }.toFloat().coerceAtLeast(1f)
val selectedClip = clips.find { it.id == selectedClipId } ?: clips.firstOrNull()
LaunchedEffect(isPlaying, totalDuration) {
while (isPlaying) {
delay(100)
val next = playheadSec + 0.1f
if (next >= totalDuration) {
playheadSec = 0f
isPlaying = false
} else {
playheadSec = next
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(14.dp)) {
// Studio Top Bar
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("VibeCut Studio", fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text(
text = String.format("00:%04.1f / 00:%04.1f β’ %s", playheadSec, totalDuration, aspectRatio),
fontSize = 12.sp,
color = Color(0xFF38BDF8),
fontFamily = FontFamily.Monospace
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
aspectRatio = when (aspectRatio) {
"9:16" -> "16:9"
"16:9" -> "1:1"
else -> "9:16"
}
},
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
) {
Text(aspectRatio, fontSize = 12.sp)
}
Button(
onClick = { showExportModal = true },
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp)
) {
Text("Export 1080p", fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
// Video Preview Monitor
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF0F172A)),
contentAlignment = Alignment.Center
) {
val activeColor = selectedClip?.color ?: Color(0xFF6366F1)
Box(
modifier = Modifier
.fillMaxHeight(0.9f)
.fillMaxWidth(if (aspectRatio == "9:16") 0.58f else if (aspectRatio == "1:1") 0.78f else 0.94f)
.clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(listOf(activeColor, Color(0xFF090D16))))
.padding(16.dp)
) {
Surface(
color = Color.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.TopStart)
) {
Text(
text = "${selectedClip?.title ?: "No Clip"} β’ ${selectedClip?.filterName} (${selectedClip?.speed}x)",
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
if (overlayTitle.isNotBlank()) {
Surface(
color = Color.Black.copy(alpha = 0.65f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)
) {
Text(
text = overlayTitle,
color = Color(0xFFFDE047),
fontWeight = FontWeight.ExtraBold,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Transport & Playhead Scrubber Bar
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
FilledTonalButton(
onClick = { isPlaying = !isPlaying },
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp)
) {
Text(if (isPlaying) "βΈ Pause" else "βΆ Play", fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
Slider(
value = playheadSec.coerceIn(0f, totalDuration),
onValueChange = { playheadSec = it },
valueRange = 0f..totalDuration,
modifier = Modifier.weight(1f)
)
OutlinedButton(
enabled = selectedClip != null && selectedClip.durationSec >= 2f,
onClick = {
val idx = clips.indexOfFirst { it.id == selectedClipId }
if (idx != -1) {
val orig = clips[idx]
val half = orig.durationSec / 2f
val newId = (clips.maxOfOrNull { it.id } ?: 0) + 1
clips[idx] = orig.copy(durationSec = half, title = "${orig.title.substringBefore(".")}_A.mp4")
clips.add(idx + 1, orig.copy(id = newId, durationSec = half, title = "${orig.title.substringBefore(".")}_B.mp4"))
}
},
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
) {
Text("β Split", fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Multi-Track Non-Linear Timeline Card
Card(
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("VIDEO TRACK (${clips.size} clips)", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color(0xFF94A3B8))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(clips, key = { it.id }) { clip ->
val isSelected = clip.id == selectedClipId
Box(
modifier = Modifier
.width((clip.durationSec * 28).coerceAtLeast(80f).dp)
.height(54.dp)
.clip(RoundedCornerShape(12.dp))
.background(clip.color.copy(alpha = 0.85f))
.border(
width = if (isSelected) 2.5.dp else 1.dp,
color = if (isSelected) Color.White else Color.Transparent,
shape = RoundedCornerShape(12.dp)
)
.clickable { selectedClipId = clip.id }
.padding(8.dp)
) {
Column {
Text(clip.title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1)
Text("${String.format("%.1f", clip.durationSec)}s β’ ${clip.speed}x", fontSize = 10.sp, color = Color.White.copy(alpha = 0.85f))
}
}
}
}
selectedClip?.let { current ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf(0.5f, 1.0f, 1.5f, 2.0f).forEach { spd ->
FilterChip(
selected = current.speed == spd,
onClick = {
val idx = clips.indexOfFirst { it.id == current.id }
if (idx != -1) clips[idx] = clips[idx].copy(speed = spd)
},
label = { Text("${spd}x", fontSize = 11.sp) }
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Natural", "Teal & Orange", "Cyber Neon").forEach { lut ->
FilterChip(
selected = current.filterName == lut,
onClick = {
val idx = clips.indexOfFirst { it.id == current.id }
if (idx != -1) clips[idx] = clips[idx].copy(filterName = lut)
},
label = { Text(lut.take(6), fontSize = 11.sp) }
)
}
}
}
}
OutlinedTextField(
value = overlayTitle,
onValueChange = { overlayTitle = it },
label = { Text("Text Title Track Overlay") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
}
if (showExportModal) {
AlertDialog(
onDismissRequest = { showExportModal = false },
title = { Text("Exporting 1080p MP4 (H.264)", fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Stitching ${clips.size} timeline clips (${String.format("%.1f", totalDuration)}s total) with text overlay track...")
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Button(onClick = { showExportModal = false }) {
Text("Done")
}
}
)
}
}
How to Debug Timeline & Media3 Issues
Keep video decoding and MP4 export reliable with these three Media3 practices:
Video Plays in Background
Always tie your ExoPlayer lifecycle to DisposableEffect(LocalLifecycleOwner.current):
"Pause exoPlayer on ON_PAUSE and call exoPlayer.release() inside onDispose { }."
Mixed Clip Resolutions Fail Export
Normalize clip dimensions when stitching multiple videos in Media3 Transformer:
"Attach Presentation.createForHeight(1080) to each EditedMediaItem so all clips match."
Laggy Playhead Scrubbing
Use keyframe sync seeking while the user drags the timeline slider: exoPlayer.setSeekParameters(SeekParameters.CLOSEST_SYNC)
The Vibe Prompt Vault
Copy-paste prompts to connect real MP4 files, Jetpack Media3 hardware export, and audio waveform lanes.
π¬ Prompt: Wire Preview Monitor to Real Video Files with Media3 ExoPlayer
"Add a 'Add Video Clip' button using ActivityResultContracts.PickVisualMedia(PickVisualMedia.VideoOnly). Load selected video URIs into a Jetpack Media3 ExoPlayer instance rendered via AndroidView(PlayerView), and sync the Compose playhead slider with exoPlayer.currentPosition."
βοΈ Prompt: Export Trimmed & Merged MP4 with Jetpack Media3 Transformer
"Write a Media3 Transformer helper that builds an EditedMediaItemSequence from our timeline clips (applying MediaItem.ClippingConfiguration for trim points and TextOverlay for the title), exports an H.264 MP4 file to the cache directory, and polls Transformer.getProgress() to update our Compose progress bar."
π΅ Prompt: Add Dual-Handle Clip Trimmer & Audio Waveform Track
"Add a RangeSlider trim control for the currently selected TimelineClip so the user can trim startSec and endSec non-destructively. Also add a Canvas audio waveform lane underneath the video track row with a Mute Original Audio toggle and Music Volume slider."