Skip to main content

How to Make a Video Editor App

Copied to clipboard!
No boilerplate memorization required

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 Philosophy

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 ClippingConfiguration and Transformer for GPU export.
  • Switch aspect ratios (9:16 Reels, 16:9 Cinema, 1:1 Square) with 1 tap.
Execution

The 4-Step Blueprint

From an empty Android Studio project to a multi-track non-linear video editor.

Step 01

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.

πŸ’‘ Pro Vibe Tip: Validate your timeline playhead, clip splitting, and speed controls in a single file before adding external video codecs.
Step 02

Configure Your Timeline

Pick your studio aesthetic, timeline engine, and effects toolkit in the configurator below, then paste the prompt into Claude or Gemini.

πŸ”‘ Key Constraint: Ask the AI to compute total duration dynamically as clips.sumOf { it.durationSec / it.speed } so speed changes update the scrubber immediately.
Step 03

Single-File Drop-In

Paste the generated code into MainActivity.kt to test playhead scrubbing, clip splitting, speed ramps, LUT presets, and text overlays.

✨ Iteration Secret: Scale each clip block's width in the timeline row proportionally to clip.durationSec so longer clips look wider visually.
Step 04

Wire Media3 Transformer

Use the Prompt Vault below to attach real MP4 video picking and hardware-accelerated H.264 rendering via Jetpack Media3.

πŸ† The Grand Payoff: A custom CapCut-style non-linear editor running natively on your Android phone with zero watermarks.
Interactive Tool

Vibe Prompt Configurator

Customize your editing suite aesthetic, timeline engine, and effects pipeline. Copy the generated prompt directly into Claude, Cursor, or Gemini.

Special Feature Vibe
Optional custom details added straight into your master prompt.
Generated Master Prompt
Ready to paste into Claude, Gemini, or ChatGPT Single-file Compose ready
Product Spec

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.5x slow-mo to 2.0x fast-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:16 Reels, 16:9 YouTube, and 1:1 Square.

UX Checklist Before Shipping: Display a monospace timecode readout (00:04.2 / 00:15.0), highlight the selected timeline clip with a crisp white border, and pause playback automatically when the user leaves the app.
Starter Template

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.

app/src/main/java/.../MainActivity.kt
Kotlin + Compose
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")
                }
            }
        )
    }
}
Troubleshooting

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)

Extensions

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
Prompt Template

"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
Prompt Template

"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
Prompt Template

"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."

We use cookies for ads and analytics.