diff --git a/AI Video Clipper/README.md b/AI Video Clipper/README.md new file mode 100644 index 0000000..e3de518 --- /dev/null +++ b/AI Video Clipper/README.md @@ -0,0 +1,63 @@ +# Gemini Video Editing App + +--- +An Android application demonstrating automated video highlight generation and intelligent cinematic editing suggestion using Vertex AI for Firebase (Gemini 2.5 Pro) and Firebase Cloud Storage. +Users can select multiple videos, specify editing goals in natural language (e.g., "create a fast-paced 15-second action reel"), upload them to Cloud Storage, and let Gemini analyze the content to recommend and preview video trims and edits. + +## Prerequisites & Project Setup +This project uses **Firebase Cloud Storage** to host video files and **Vertex AI for Firebase** (Gemini 2.5 Pro) for advanced media analysis and editing recommendations. Follow the setup steps below to configure your Firebase and Google Cloud project. +### 1. Firebase Project Configuration +1. Go to the [Firebase Console](https://console.firebase.google.com/). +2. Click **Add project** and create a new project (or select an existing one). +3. Vertex AI for Firebase requires the project to be on the pay-as-you-go **Blaze plan**. In the Firebase Console, upgrade your project by clicking **Upgrade** in the bottom-left corner. +4. Register your Android App: + - Click the Android icon to add a new app. + - Enter the Android package name: `com.example.videoediting`. + - Click **Register app**. +5. Download the `google-services.json` configuration file and place it in the `app/` directory of this project: + ```path + /app/google-services.json + ``` +--- +### 2. Enable Firebase Storage +1. In the Firebase Console sidebar, go to **Build** > **Storage**. +2. Click **Get Started**. +3. Select a starting rule configuration (e.g., test mode) and choose your Storage location (e.g., `us-central1`). +4. Once created, note your bucket URI (formatted as `gs://YOUR_PROJECT_ID.firebasestorage.app` or `gs://YOUR_PROJECT_ID.appspot.com`). +5. **Create a `videos` folder**: + - Within the Storage console dashboard, click the **New folder** icon. + - Name the folder `videos`. + - *Note: While the app programmatically creates this folder upon upload, creating it manually ensures it is initialized for your project.* +6. Set the **Storage Rules** to allow read/write access for uploads. For example, during development: + ```javascript + rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /videos/{allPaths=**} { + allow read, write: if true; + } + } + } + ``` +--- +### 3. Enable Vertex AI for Firebase (AI Logic) +1. In the Firebase Console sidebar, go to **Build** > **Vertex AI** (or **Build with Gemini**). +2. Click **Get Started** and follow the prompts. +3. This activates the necessary Vertex AI APIs on the underlying Google Cloud project and sets up billing integration. +--- +### 4. Configure Google Cloud Storage Permissions for Vertex AI +Since Gemini processes video files directly from Google Cloud Storage, the Google Cloud Vertex AI service agent requires permission to read objects from your Storage bucket. +1. Find your **Google Cloud Project Number**: + - In the Firebase Console, click the Gear icon next to **Project Overview** and select **Project Settings**. + - Copy the **Project number** (e.g., `123456789012`). +2. Open the [Google Cloud Console IAM Page](https://console.cloud.google.com/iam-admin/iam). +3. Ensure you are in the correct project. +4. Check the **Include Google-provided role grants** box in the top-right corner of the IAM principal list. +5. Search for the **Vertex AI Service Agent** service account: + ```text + service-PROJECT_NUMBER@gcp-sa-aiplatform.iam.gserviceaccount.com + ``` + *(Replace `PROJECT_NUMBER` with your actual project number).* +6. Click the edit icon (pencil) next to this service account principal to modify its roles. +7. Click **Add another role** and select **Storage Object Viewer** (`roles/storage.objectViewer`). +8. Save the permissions. diff --git a/AI Video Clipper/app/.gitignore b/AI Video Clipper/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/AI Video Clipper/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/AI Video Clipper/app/build.gradle.kts b/AI Video Clipper/app/build.gradle.kts new file mode 100644 index 0000000..305c3d2 --- /dev/null +++ b/AI Video Clipper/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + id("com.google.gms.google-services") +} + +android { + namespace = "com.example.videoediting" + compileSdk = 37 + + defaultConfig { + applicationId = "com.example.videoediting" + minSdk = 30 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.common) + implementation(libs.firebase.storage) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.effect) + implementation(libs.androidx.media3.transformer) + implementation(libs.androidx.media3.ui.compose) + implementation(libs.androidx.media3.inspector) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.guava) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} \ No newline at end of file diff --git a/AI Video Clipper/app/google-services.json b/AI Video Clipper/app/google-services.json new file mode 100644 index 0000000..e69de29 diff --git a/AI Video Clipper/app/proguard-rules.pro b/AI Video Clipper/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/AI Video Clipper/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/AI Video Clipper/app/src/androidTest/java/com/example/videoediting/ExampleInstrumentedTest.kt b/AI Video Clipper/app/src/androidTest/java/com/example/videoediting/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..bdddc5c --- /dev/null +++ b/AI Video Clipper/app/src/androidTest/java/com/example/videoediting/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.videoediting + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.videoediting", appContext.packageName) + } +} \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/AndroidManifest.xml b/AI Video Clipper/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ea13587 --- /dev/null +++ b/AI Video Clipper/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/AIResponseJsonSchema.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/AIResponseJsonSchema.kt new file mode 100644 index 0000000..59be042 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/AIResponseJsonSchema.kt @@ -0,0 +1,18 @@ +package com.example.videoediting + +import com.google.firebase.ai.type.Schema + +val jsonSchema = Schema.obj(mapOf( + "videos" to Schema.array( + Schema.obj(mapOf( + "uri" to Schema.string(), + "mostEngagingSegment" to Schema.obj( + mapOf( + "startMs" to Schema.long("A start ms value for clipping"), + "endMs" to Schema.long("End ms value for clipping"), + "reasoning" to Schema.string("reasoning for not finding a clipping range") + ) + ), + )) + )) +) \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/FirebaseStorageRepository.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/FirebaseStorageRepository.kt new file mode 100644 index 0000000..58f99d2 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/FirebaseStorageRepository.kt @@ -0,0 +1,27 @@ +package com.example.videoediting + +import android.net.Uri +import android.util.Log +import com.google.firebase.storage.FirebaseStorage +import kotlinx.coroutines.tasks.await +import java.util.UUID + +class FirebaseStorageRepository(private val storage: FirebaseStorage) { + + suspend fun uploadFile(uri: Uri): String? { + return try { + val storageRef = storage.reference + val videoRef = storageRef.child("videos/${UUID.randomUUID()}.mp4") + val uploadTask = videoRef.putFile(uri) + uploadTask.await() // Wait for the upload to complete + val path = videoRef.path + val bucket = videoRef.bucket + val storageUrl = "gs://$bucket$path" + Log.d("FirebaseStorageRepo", "File uploaded successfully: $storageUrl") + storageUrl + } catch (e: Exception) { + Log.e("FirebaseStorageRepo", "Error uploading file", e) + null + } + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/GeminiRepository.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/GeminiRepository.kt new file mode 100644 index 0000000..8133684 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/GeminiRepository.kt @@ -0,0 +1,54 @@ +package com.example.videoediting + +import android.net.Uri +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.content +import com.google.firebase.ai.type.generationConfig +import com.google.firebase.ai.type.thinkingConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GeminiRepository { + + private val aiModel = Firebase.ai(backend = GenerativeBackend.vertexAI()) + .generativeModel( + modelName = "gemini-2.5-pro", + generationConfig = generationConfig { + temperature = 0f + responseMimeType = "application/json" + responseSchema = jsonSchema + thinkingConfig = thinkingConfig { + thinkingBudget = 1024 + includeThoughts = true + } + } + ) + + suspend fun generateEditResponse(promptData: String, uriPairs: List>): GeminiResponse = withContext(Dispatchers.IO) { + val gsUris = uriPairs.map { it.first } + val requestContent = content { + gsUris.forEach { gsUri -> + fileData(gsUri, "video/mp4") + } + + text(promptData) + } + val response = aiModel.generateContent(requestContent) + + val aiThoughts = if (response.thoughtSummary.isNullOrEmpty()) null else response.thoughtSummary + val aiResponseText = if (response.text.isNullOrEmpty()) null else response.text + Log.d("GeminiRepository", "response $aiResponseText") + val aiTokensUsed = response.usageMetadata?.thoughtsTokenCount ?: -1 + + GeminiResponse(aiThoughts, aiResponseText, aiTokensUsed) + } +} + +data class GeminiResponse( + val thoughts: String?, + val responseText: String?, + val tokensUsed: Int +) diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/GenAIParser.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/GenAIParser.kt new file mode 100644 index 0000000..5315b6e --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/GenAIParser.kt @@ -0,0 +1,67 @@ +package com.example.videoediting + +import android.util.Log +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +object GenAIParser { + private const val TAG = "GenAIParser" + + // 1. JSON DTOs matching the exact schema returned by Gemini + @Serializable + private data class MostEngagingSegmentDto( + val startMs: Long? = null, + val endMs: Long? = null, + val reasoning: String? = null + ) + + @Serializable + private data class VideoSegmentDto( + val uri: String, + val mostEngagingSegment: MostEngagingSegmentDto? = null + ) + + @Serializable + private data class AIResponseDto( + val videos: List = emptyList() + ) + + // 2. Public Domain Models (Unchanged to prevent breaking callers) + data class SegmentTimes(val startMs: Long, val endMs: Long) + data class VideoSegment(val uri: String, val segmentTimes: SegmentTimes?, val reasoning: String?) + + private val jsonConfig = Json { + ignoreUnknownKeys = true // Resilient to extra fields returned by the AI + } + + /** + * Parses a JSON string conforming to AIResponseJsonSchema and returns a list of VideoSegment objects. + * + * @param jsonString The JSON string response from the Gemini agent. + * @return A [List] of [VideoSegment] objects. Returns an empty list on failure. + */ + fun parseVideoSegments(jsonString: String): List { + return try { + val responseDto = jsonConfig.decodeFromString(jsonString) + responseDto.videos.map { dto -> + val segmentDto = dto.mostEngagingSegment + var segmentTimes: SegmentTimes? = null + var reasoning: String? = null + + if (segmentDto != null) { + val startMs = segmentDto.startMs ?: -1L + val endMs = segmentDto.endMs ?: -1L + reasoning = segmentDto.reasoning + + if (startMs >= 0 && endMs > startMs) { + segmentTimes = SegmentTimes(startMs, endMs) + } + } + VideoSegment(dto.uri, segmentTimes, reasoning) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse video segments JSON with kotlinx-serialization", e) + emptyList() + } + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/MainActivity.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/MainActivity.kt new file mode 100644 index 0000000..ab6c062 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/MainActivity.kt @@ -0,0 +1,30 @@ +package com.example.videoediting + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.media3.common.util.UnstableApi +import com.example.videoediting.ui.theme.GeminiVideoEditingTheme + +class MainActivity : ComponentActivity() { + @OptIn(UnstableApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + GeminiVideoEditingTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + MediaSelectionScreen( + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionScreen.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionScreen.kt new file mode 100644 index 0000000..7810cd1 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionScreen.kt @@ -0,0 +1,288 @@ +package com.example.videoediting + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.PlayArrow +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.UnstableApi +import com.example.videoediting.ui.theme.GeminiVideoEditingTheme + +@UnstableApi +@Composable +fun MediaSelectionScreen( + modifier: Modifier = Modifier, + viewModel: MediaSelectionViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + val pickVideoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(), + onResult = { uris -> + if (uris.isNotEmpty()) { + viewModel.updateSelectedVideos(uris) + } + } + ) + + // Handle Snackbar for errors + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { msg -> + snackbarHostState.showSnackbar(msg) + viewModel.clearError() + } + } + + if (uiState.aiResponse != null && uiState.selectedVideoUris.isNotEmpty()) { + VideoAnalysisResultScreen( + jsonResponse = uiState.aiResponse!!, + uriPairs = uiState.uriPairs, + modifier = modifier + ) + } else { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier.fillMaxSize() + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues) + .padding(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + ) { + // Central Icon with circle background + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), // Theme-based circle background + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier.size(60.dp), + contentAlignment = Alignment.Center + ) { + // Background card + Box( + modifier = Modifier + .size(40.dp) + .offset(x = (-4).dp, y = (-4).dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)) + ) + + // Foreground card + Box( + modifier = Modifier + .size(40.dp) + .offset(x = 4.dp, y = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = if (uiState.selectedVideoUris.isNotEmpty()) stringResource(R.string.video_selected) else stringResource(R.string.no_media_selected), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = if (uiState.selectedVideoUris.isNotEmpty()) { + stringResource(R.string.videos_selected, uiState.selectedVideoUris.size) + } else { + stringResource(R.string.choose_videos) + }, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Button( + onClick = { + pickVideoLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly) + ) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(50.dp), // Pill shaped + modifier = Modifier.height(56.dp) + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add", + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.select_videos), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = uiState.intentText, + onValueChange = { viewModel.updateIntentText(it) }, + placeholder = { Text(stringResource(R.string.describe_intent)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedBorderColor = MaterialTheme.colorScheme.primary + ) + ) + } + + Button( + onClick = { + viewModel.onEditWithGeminiClicked() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .align(Alignment.BottomCenter), + enabled = uiState.selectedVideoUris.isNotEmpty() && !uiState.isLoading + ) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = "Process", + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.edit_with_gemini), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + + // Loading Dialog + if (uiState.isLoading) { + Dialog(onDismissRequest = { /* Do nothing to prevent dismiss */ }) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Top colored border + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .background(MaterialTheme.colorScheme.primary) // Dynamic primary border + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Loader + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.gemini_processing), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.applying_edits), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@UnstableApi +@Composable +fun MediaSelectionScreenPreview() { + GeminiVideoEditingTheme { + // Just for previewing theme + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionViewModel.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionViewModel.kt new file mode 100644 index 0000000..2b9f413 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/MediaSelectionViewModel.kt @@ -0,0 +1,130 @@ +package com.example.videoediting + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.storage.storage +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MediaSelectionUiState( + val selectedVideoUris: List = emptyList(), + val isLoading: Boolean = false, + val intentText: String = "", + val errorMessage: String? = null, + val uriPairs: List> = emptyList(), + val aiResponse: String? = null, + val aiThoughts: String? = null +) + +class MediaSelectionViewModel(application: Application) : AndroidViewModel(application) { + + // In a real app with DI, these would be injected + private val storageRepository by lazy { + val storage = Firebase.storage.apply { + maxUploadRetryTimeMillis = 30000 + maxOperationRetryTimeMillis = 30000 + } + FirebaseStorageRepository(storage) + } + private val geminiRepository = GeminiRepository() + + private val _uiState = MutableStateFlow(MediaSelectionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateSelectedVideos(uris: List) { + _uiState.update { it.copy(selectedVideoUris = uris) } + } + + fun updateIntentText(text: String) { + _uiState.update { it.copy(intentText = text) } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun onEditWithGeminiClicked() { + val currentState = _uiState.value + if (currentState.selectedVideoUris.isEmpty()) return + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + viewModelScope.launch { + try { + // 1. Upload files in parallel + val newUriPairs = coroutineScope { + currentState.selectedVideoUris.map { localUri -> + async { + val uploadedUri = storageRepository.uploadFile(localUri) + if (uploadedUri != null) { + uploadedUri to localUri + } else { + Log.e("MediaSelectionVM", "Failed to upload $localUri") + _uiState.update { it.copy(errorMessage = "Failed to upload video: $localUri") } + null + } + } + }.awaitAll().filterNotNull() + } + + _uiState.update { it.copy(uriPairs = newUriPairs) } + + if (newUriPairs.isEmpty()) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "No files were uploaded successfully. Cannot proceed." + ) + } + return@launch + } + + // 2. Generate content + // include a list of files to analyze + val gsUris = newUriPairs.map { it.first } + val uriListForPrompt = gsUris.joinToString("\n") { "- $it" } + + val context = getApplication() + val promptData = context.resources.getString(R.string.generate_simple_preamble, currentState.intentText, uriListForPrompt) + + val response = geminiRepository.generateEditResponse(promptData, newUriPairs) + + if (response.responseText != null) { + _uiState.update { + it.copy( + isLoading = false, + aiResponse = response.responseText, + aiThoughts = response.thoughts + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to get a response from Gemini." + ) + } + } + + } catch (e: Exception) { + Log.e("MediaSelectionVM", "Error processing video", e) + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "An error occurred: ${e.localizedMessage}" + ) + } + } + } + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisResultScreen.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisResultScreen.kt new file mode 100644 index 0000000..e3b2941 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisResultScreen.kt @@ -0,0 +1,197 @@ +package com.example.videoediting + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +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.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.ExperimentalApi +import androidx.media3.common.util.UnstableApi +import androidx.media3.transformer.CompositionPlayer +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.buttons.PlayPauseButton +import androidx.media3.ui.compose.state.PlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState + +@UnstableApi +@OptIn(ExperimentalApi::class) +@Composable +fun VideoAnalysisResultScreen( + jsonResponse: String, + uriPairs: List>, + modifier: Modifier = Modifier, + viewModel: VideoAnalysisViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + DisposableEffect(viewModel) { + onDispose { + viewModel.releasePlayer() + } + } + + LaunchedEffect(jsonResponse, uriPairs) { + viewModel.initialize(jsonResponse, uriPairs) + } + + if (uiState.isLoading) { + LoadingScreen(modifier) + } else { + ResultContent( + uiState = uiState, + modifier = modifier + ) + } +} + +@Composable +private fun LoadingScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@UnstableApi +@OptIn(ExperimentalApi::class) +@Composable +private fun ResultContent( + uiState: VideoAnalysisUiState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + VideoPlayerSection(uiState.player) + } + + item { + ReasoningHeader() + } + + itemsIndexed(uiState.videos) { index, video -> + ReasoningCard(index, video) + } + } +} + +@UnstableApi +@OptIn(ExperimentalApi::class) +@Composable +private fun VideoPlayerSection(player: CompositionPlayer?) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(480.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black) + ) { + if (player != null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + PlayerSurface( + player = player, modifier = Modifier.weight(1f) + ) + + val buttonState = rememberPlayPauseButtonState(player) + PlayPauseButton(buttonState) + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color.White) + } + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun PlayPauseButton(buttonState: PlayPauseButtonState) { + val icon = + if (buttonState.showPlay) painterResource(R.drawable.rounded_play_arrow_24) else painterResource( + R.drawable.rounded_pause_24 + ) + val contentDescription = if (buttonState.showPlay) "Play" else "Pause" + FilledIconButton(onClick = buttonState::onClick, enabled = buttonState.isEnabled) { + Icon(icon, contentDescription) + } +} +@Composable +private fun ReasoningHeader() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Reasonings", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +@Composable +private fun ReasoningCard(index: Int, video: GenAIParser.VideoSegment) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Segment ${index + 1}", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = video.reasoning ?: "No reasoning provided", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + } + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisViewModel.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisViewModel.kt new file mode 100644 index 0000000..b17e56c --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/VideoAnalysisViewModel.kt @@ -0,0 +1,126 @@ +package com.example.videoediting + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.annotation.OptIn +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.util.ExperimentalApi +import androidx.media3.common.util.UnstableApi +import androidx.media3.inspector.MetadataRetriever +import androidx.media3.transformer.Composition +import androidx.media3.transformer.Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL +import androidx.media3.transformer.CompositionPlayer +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.EditedMediaItemSequence +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch + +@UnstableApi +@OptIn(ExperimentalApi::class) +data class VideoAnalysisUiState( + val videos: List = emptyList(), + val durations: Map = emptyMap(), + val isLoading: Boolean = true, + val player: CompositionPlayer? = null, +) + +@UnstableApi +class VideoAnalysisViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(VideoAnalysisUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initialize(jsonResponse: String, uriPairs: List>) { + if (_uiState.value.videos.isNotEmpty()) return // Already initialized + + val videos = GenAIParser.parseVideoSegments(jsonResponse) + _uiState.update { it.copy(videos = videos) } + + val context = getApplication() + + viewModelScope.launch { + val durations = coroutineScope { + videos.map { video -> + async { + val localUri = uriPairs.find { it.first == video.uri }?.second + if (localUri != null) { + try { + val mediaItem = MediaItem.fromUri(localUri) + val retriever = MetadataRetriever.Builder(context, mediaItem).build() + val durationUs = retriever.retrieveDurationUs().await() + retriever.close() + video.uri to durationUs + } catch (e: Exception) { + Log.e("VideoAnalysisVM", "Error retrieving duration for ${video.uri}", e) + null + } + } else { + null + } + } + }.awaitAll().filterNotNull().toMap() + } + _uiState.update { it.copy(durations = durations, isLoading = false) } + setupPlayer(uriPairs) + } + } + + @UnstableApi + @OptIn(ExperimentalApi::class) + private fun setupPlayer(uriPairs: List>) { + val context = getApplication() + val currentState = _uiState.value + val editedMediaItems = currentState.videos.map { video -> + val localUri = uriPairs.find { it.first == video.uri }?.second + val durationUs = currentState.durations[video.uri] ?: 0L + + val mediaItemBuilder = MediaItem.Builder().setUri(localUri) + + video.segmentTimes?.let { + val totalDurationMs = durationUs / 1000 + val endMs = it.endMs.coerceIn(0, totalDurationMs) + val startMs = it.startMs.coerceIn(0, endMs) + mediaItemBuilder.setClippingConfiguration( + MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(startMs) + .setEndPositionMs(endMs) + .build() + ) + } + + EditedMediaItem.Builder(mediaItemBuilder.build()) + .setDurationUs(durationUs) + .build() + } + + val composition = Composition.Builder( + EditedMediaItemSequence.withAudioAndVideoFrom(editedMediaItems) + ).setHdrMode(HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL).build() + + val player = CompositionPlayer.Builder(context).build().apply { + setComposition(composition) + prepare() + } + + _uiState.update { it.copy(player = player) } + } + + fun releasePlayer() { + _uiState.value.player?.release() + _uiState.update { it.copy(player = null) } + } + + override fun onCleared() { + super.onCleared() + releasePlayer() + } +} diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Color.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Color.kt new file mode 100644 index 0000000..550eb70 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.videoediting.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Theme.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Theme.kt new file mode 100644 index 0000000..31f5bd6 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Theme.kt @@ -0,0 +1,47 @@ +package com.example.videoediting.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun GeminiVideoEditingTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Type.kt b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Type.kt new file mode 100644 index 0000000..8e30844 --- /dev/null +++ b/AI Video Clipper/app/src/main/java/com/example/videoediting/ui/theme/Type.kt @@ -0,0 +1,18 @@ +package com.example.videoediting.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/drawable/ic_launcher_background.xml b/AI Video Clipper/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..61bb79e --- /dev/null +++ b/AI Video Clipper/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AI Video Clipper/app/src/main/res/drawable/ic_launcher_foreground.xml b/AI Video Clipper/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..966abaf --- /dev/null +++ b/AI Video Clipper/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/drawable/rounded_pause_24.xml b/AI Video Clipper/app/src/main/res/drawable/rounded_pause_24.xml new file mode 100644 index 0000000..a67f446 --- /dev/null +++ b/AI Video Clipper/app/src/main/res/drawable/rounded_pause_24.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/drawable/rounded_play_arrow_24.xml b/AI Video Clipper/app/src/main/res/drawable/rounded_play_arrow_24.xml new file mode 100644 index 0000000..9ff05ee --- /dev/null +++ b/AI Video Clipper/app/src/main/res/drawable/rounded_play_arrow_24.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..5ad9ce1 --- /dev/null +++ b/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ad9ce1 --- /dev/null +++ b/AI Video Clipper/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/AI Video Clipper/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/AI Video Clipper/app/src/main/res/values/colors.xml b/AI Video Clipper/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..09837df --- /dev/null +++ b/AI Video Clipper/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/values/prompts.xml b/AI Video Clipper/app/src/main/res/values/prompts.xml new file mode 100644 index 0000000..016ea87 --- /dev/null +++ b/AI Video Clipper/app/src/main/res/values/prompts.xml @@ -0,0 +1,50 @@ + + + + You are an expert video analyst and cinematic editor. Your task is to analyze the provided videos and recommend how to edit them for maximum impact. + + Please provide two things: + 1. Identify the single most engaging, climactic, or visually striking segment in EACH video provided in the "FILES TO ANALYZE" list. + 2. Following the user\'s intent, suggest a sequence of these segments and return them in a JSON object. + + Analyze movement, lighting, subject focus, and emotional peaks for each video. + + Your final output must be a single JSON object containing an entry for EVERY video listed. + + 1. JSON Structure for Response + Your primary output format is a JSON object containing an array with video URIs and the most engaging segment of the video. + + CRITICAL: The "uri" field for each object in the "videos" array MUST be an EXACT string match to one of the URIs provided in the "FILES TO ANALYZE" section. Do not shorten, change, or invent URIs. + + -BEGIN JSON STRUCTURE DEFINITION- + + { + "videos": [ + { + "uri": "string", + "mostEngagingSegment": { + "startMs": "long", + "endMs": "long", + "reasoning": "string", + }, + } + ] + } + -END JSON STRUCTURE DEFINITION- + + 2. Error Handling + If you cannot fulfill the user\'s request, return a JSON object with a single error key. The + explanation must be human-readable and at most 15 words. + + -BEGIN ERROR TEMPLATE- + + { + "error": "A brief explanation of why the request could not be fulfilled." + } + -END ERROR TEMPLATE- + + Desired user intent: %1$s + FILES TO ANALYZE: %2$s + CRITICAL: You must return a JSON object where the \'uri\' field for each entry exactly matches one of the \'gs://\' URIs listed above. + + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/values/strings.xml b/AI Video Clipper/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3d890fb --- /dev/null +++ b/AI Video Clipper/app/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + Gemini Video Editing + Video Selected + No Media Selected + Choose videos from your device to begin the AI editing process. + %1$d video(s) selected + Select Videos + Describe your intent / e.g. create a 15-second highlight reel + Edit with Gemini + Gemini is processing your video... + Applying AI edits ... + \ No newline at end of file diff --git a/AI Video Clipper/app/src/main/res/values/themes.xml b/AI Video Clipper/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ecb1e56 --- /dev/null +++ b/AI Video Clipper/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +