Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ runtimeVersion = "1.10.6"
crashlyticsVersion = "3.0.7"
googleServicesVersion = "4.4.4"
media3Version = "1.10.0"
mlkitSubjectSegmentationVersion = "16.0.0-beta1"

[libraries]
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" }
Expand Down Expand Up @@ -84,6 +85,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationComposeVersion" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtimeVersion" }
mlkit-subject-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSubjectSegmentationVersion" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }

supabase-bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "supabaseBomVersion" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ object LyricsTemplateRegistry {
GradientLyricsTemplate,
RainbowLyricsTemplate,
AccentLyricsTemplate,
VintageLyricsTemplate,
)

fun getTemplate(name: String?): MotionTemplate = templates.find { it.name == name } ?: PopupLyricsTemplate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
import com.tejpratapsingh.motionlib.templates.extensions.popUpTextView
import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
import com.tejpratapsingh.motionlib.ui.effects.BlurEffect

val PopupLyricsTemplate: MotionTemplate =
motionTemplate("Multi Lyrics Template") {
Expand Down Expand Up @@ -41,6 +42,13 @@ val PopupLyricsTemplate: MotionTemplate =
alpha = 0.4f,
startFrame = lyrics.first().frame,
endFrame = lyrics.last().frame,
effects =
listOf(
BlurEffect(
lyrics.first().frame,
lyrics.last().frame,
),
),
)

lyrics.zipWithNext().forEach { (current, next) ->
Expand Down Expand Up @@ -86,6 +94,13 @@ val PopupLyricsTemplate: MotionTemplate =
alpha = 0.4f,
startFrame = previewLyrics.first().frame,
endFrame = previewLyrics.last().frame,
effects =
listOf(
BlurEffect(
previewLyrics.first().frame,
previewLyrics.last().frame,
),
),
)

previewLyrics.zipWithNext().forEach { (current, next) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.tejpratapsingh.lyricsmaker.presentation.templates

import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.net.toUri
import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame
import com.tejpratapsingh.motionlib.core.MotionTextVariant
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideDirection
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.extensions.wordWriterTextView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
import com.tejpratapsingh.motionlib.ui.effects.VibrateEffect
import com.tejpratapsingh.motionlib.ui.effects.VintageEffect

val VintageLyricsTemplate: MotionTemplate =
motionTemplate("Vintage Lyrics Template") {
parameters {
string("songName")
string("image", defaultValue = null)
}

content {
val image = data.getString("image")
val lyrics = data.get<List<SyncedLyricFrame>>("lyrics") ?: emptyList()

if (lyrics.isNotEmpty()) {
val startFrame = lyrics.first().frame
val endFrame = lyrics.last().frame

image?.let {
motionImageView(
startFrame = startFrame,
endFrame = endFrame,
imageUri = image.toUri(),
effects =
listOf(
VibrateEffect(startFrame, endFrame, amplitude = 10f, frequency = 0.05f),
VintageEffect(startFrame, endFrame, fromIntensity = 0.8f, toIntensity = 1.0f),
),
)
}

translucentMotionView(
color = "#000000",
alpha = 0.3f,
startFrame = startFrame,
endFrame = endFrame,
)

lyrics.zipWithNext().forEach { (current, next) ->
wordWriterTextView(
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
writingSpeed = 1.0f,
textView =
AppCompatTextView(context).apply {
setPadding(32, 32, 32, 32)
textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
gravity = Gravity.CENTER
},
)
transition(SlideTransition(SlideDirection.RIGHT_TO_LEFT), duration = 15)
}
Comment on lines +53 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

zipWithNext() drops the last lyric segment (and all text when only one frame exists).

Both loops miss rendering the final lyric frame because zipWithNext() emits n-1 pairs only.

Suggested fix
-                lyrics.zipWithNext().forEach { (current, next) ->
+                lyrics.forEachIndexed { index, current ->
+                    val segmentEndFrame = lyrics.getOrNull(index + 1)?.frame ?: endFrame
                     wordWriterTextView(
                         text = current.text,
                         startFrame = current.frame,
-                        endFrame = next.frame,
+                        endFrame = segmentEndFrame,
                         textSizeVariant = MotionTextVariant.H1,
                         textColor = "`#FFFFFF`",
                         writingSpeed = 1.0f,
                         textView =
                             AppCompatTextView(context).apply {
                                 setPadding(32, 32, 32, 32)
                                 textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
                                 gravity = Gravity.CENTER
                             },
                     )
                     transition(SlideTransition(SlideDirection.RIGHT_TO_LEFT), duration = 15)
                 }
@@
-                previewLyrics.zipWithNext().forEach { (current, next) ->
+                previewLyrics.forEachIndexed { index, current ->
+                    val segmentEndFrame = previewLyrics.getOrNull(index + 1)?.frame ?: endFrame
                     wordWriterTextView(
                         text = current.text,
                         startFrame = current.frame,
-                        endFrame = next.frame,
+                        endFrame = segmentEndFrame,
                         textSizeVariant = MotionTextVariant.H1,
                         textColor = "`#FFFFFF`",
                         writingSpeed = 1.0f,
                         textView =
                             AppCompatTextView(context).apply {
                                 setPadding(32, 32, 32, 32)
                                 textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
                                 gravity = Gravity.CENTER
                             },
                     )
                     transition(SlideTransition(SlideDirection.RIGHT_TO_LEFT), duration = 15)
                 }

Also applies to: 102-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/VintageLyricsTemplate.kt`
around lines 53 - 69, The current loop using lyrics.zipWithNext() in
VintageLyricsTemplate.kt (inside the block that calls wordWriterTextView and
transition with SlideTransition/SlideDirection) omits the final lyric, causing
the last frame (and single-frame cases) to be skipped; update the rendering to
iterate all items (e.g., use forEachIndexed or a for loop over lyrics) and after
processing each pair call wordWriterTextView with current.text/current.frame and
next.frame for timing, then separately handle the final lyric element by calling
wordWriterTextView with its startFrame and an appropriate endFrame (or duration)
so the last segment is rendered; apply the same fix to the second occurrence of
zipWithNext() at the other block (lines referenced in the comment).

}
}

preview {
val image = data.getString("image")
val lyrics = data.get<List<SyncedLyricFrame>>("lyrics") ?: emptyList()

if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
val startFrame = previewLyrics.first().frame
val endFrame = previewLyrics.last().frame

image?.let {
motionImageView(
startFrame = startFrame,
endFrame = endFrame,
imageUri = image.toUri(),
effects =
listOf(
VibrateEffect(startFrame, endFrame, amplitude = 10f, frequency = 0.05f),
VintageEffect(startFrame, endFrame, fromIntensity = 0.8f, toIntensity = 1.0f),
),
)
}

translucentMotionView(
color = "#000000",
alpha = 0.3f,
startFrame = startFrame,
endFrame = endFrame,
)

previewLyrics.zipWithNext().forEach { (current, next) ->
wordWriterTextView(
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
writingSpeed = 1.0f,
textView =
AppCompatTextView(context).apply {
setPadding(32, 32, 32, 32)
textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
gravity = Gravity.CENTER
},
)
transition(SlideTransition(SlideDirection.RIGHT_TO_LEFT), duration = 15)
}
}
}
}
55 changes: 55 additions & 0 deletions modules/ml-kit-ext/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
plugins {
alias(libs.plugins.android.library)
id 'maven-publish'
}

android {
compileSdk 36

defaultConfig {
minSdk 25

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}

namespace 'com.tejpratapsingh.motionlib.mlkit'
lint {
targetSdk 36
}
testOptions {
targetSdk 36
}
publishing {
singleVariant("release")
}
}

afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
}
}

dependencies {
api project(path: ':modules:core')
implementation libs.androidx.appcompat
implementation libs.mlkit.subject.segmentation

testImplementation libs.junit
}
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.tejpratapsingh.motionlib.mlkit

import com.tejpratapsingh.motionlib.core.MotionPlugin
import com.tejpratapsingh.motionlib.mlkit.plugins.SubjectSegmentationPlugin

/**
* Entry point for ML Kit based image processing plugins.
*/
object MLKitImageProcessor {
/**
* Plugin for background removal using ML Kit Subject Segmentation.
*/
val subjectSegmentationPlugin: MotionPlugin by lazy {
SubjectSegmentationPlugin()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.tejpratapsingh.motionlib.mlkit.effects

import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Color
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import android.view.View
import androidx.core.graphics.createBitmap
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
import com.tejpratapsingh.motionlib.core.MotionEffect
import com.tejpratapsingh.motionlib.core.MotionView
import java.nio.FloatBuffer

/**
* A [MotionEffect] that uses ML Kit Subject Segmentation to remove background.
* Works only on Android T (API 33) and above due to [RenderEffect] and [RuntimeShader].
*/
class SubjectSegmentationEffect(
override val startFrame: Int,
override val endFrame: Int,
) : MotionEffect {
override lateinit var motionView: MotionView

private val options =
SubjectSegmenterOptions
.Builder()
.enableForegroundConfidenceMask()
.build()

private val segmenter = SubjectSegmentation.getClient(options)

private val maskCache = mutableMapOf<Int, Bitmap>()

Comment on lines +38 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound maskCache growth to avoid frame-count-sized bitmap retention.

At Line 38 and Line 69–79, one full-size bitmap can be retained per frame with no eviction. On long timelines this can escalate memory usage and trigger OOMs.

Suggested fix (bounded cache)
+import android.util.LruCache
...
-    private val maskCache = mutableMapOf<Int, Bitmap>()
+    private val maskCache = LruCache<Int, Bitmap>(16)
...
-        val maskBitmap =
-            maskCache[frame] ?: run {
+        val maskBitmap =
+            maskCache.get(frame) ?: run {
...
-                        maskCache[frame] = createdMask
+                        maskCache.put(frame, createdMask)
                         createdMask

Also applies to: 68-80

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/ml-kit-ext/src/main/java/com/tejpratapsingh/motionlib/mlkit/effects/SubjectSegmentationEffect.kt`
around lines 38 - 39, The maskCache currently (in SubjectSegmentationEffect) is
an unbounded mutableMap and can retain one full-size Bitmap per frame; replace
it with a bounded cache (e.g., an LruCache<Int, Bitmap> or a LinkedHashMap with
access-order eviction) keyed the same way as maskCache so entries are evicted
when size exceeds a configured max (bytes or entry count), and ensure evicted
Bitmaps are recycled/closed to free memory; update all uses of maskCache (where
put/replace happens in the class) to use the new cache API and make the cache
access thread-safe if methods like produceMask() or onFrameProcessed() access it
from different threads.

@Suppress("ktlint:standard:property-naming")
private val MASK_SHADER =
"""
uniform shader content;
uniform shader mask;

half4 main(float2 fragCoord) {
half4 color = content.eval(fragCoord);
half4 maskColor = mask.eval(fragCoord);
// Use alpha channel of the mask for transparency
return color * maskColor.a;
}
""".trimIndent()

override fun forFrame(frame: Int): MotionView {
if (motionView !is View) return motionView
val view = motionView as View

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return motionView

if (frame !in startFrame..endFrame) {
// If we are past the end frame, clear the effect
if (frame > endFrame) {
view.setRenderEffect(null)
}
return motionView
Comment on lines +60 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale RenderEffect whenever segmentation is inactive.

At Line 60 and Line 62, the effect is cleared only when frame > endFrame. If playback seeks to a frame before startFrame, the previously applied effect can persist. Also at Line 89–95, when maskBitmap is null, the prior effect is not reset.

Suggested fix
-        if (frame !in startFrame..endFrame) {
-            // If we are past the end frame, clear the effect
-            if (frame > endFrame) {
-                view.setRenderEffect(null)
-            }
+        if (frame !in startFrame..endFrame) {
+            view.setRenderEffect(null)
             return motionView
         }
...
-        if (maskBitmap != null) {
+        if (maskBitmap != null) {
             val shader = RuntimeShader(MASK_SHADER)
             val maskShader = BitmapShader(maskBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
             shader.setInputShader("mask", maskShader)
 
             view.setRenderEffect(RenderEffect.createRuntimeShaderEffect(shader, "content"))
+        } else {
+            view.setRenderEffect(null)
         }

Also applies to: 89-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/ml-kit-ext/src/main/java/com/tejpratapsingh/motionlib/mlkit/effects/SubjectSegmentationEffect.kt`
around lines 60 - 65, The code only clears the RenderEffect when frame >
endFrame and when maskBitmap is null it leaves the prior effect in place; update
the logic in SubjectSegmentationEffect (the method that checks frame against
startFrame..endFrame and the branch that handles maskBitmap) to always clear the
prior effect via view.setRenderEffect(null) whenever the segmentation is
inactive (i.e., frame not in startFrame..endFrame, including frame < startFrame)
and also when maskBitmap is null, then return motionView; ensure you reference
the existing calls to view.setRenderEffect(null), the startFrame and endFrame
bounds check, and the maskBitmap null-check while making this change.

}

val maskBitmap =
maskCache[frame] ?: run {
val bitmap = motionView.getViewBitmap()
val image = InputImage.fromBitmap(bitmap, 0)
try {
// This is synchronous and can be slow.
// Ideally, this should be pre-processed or run on a background thread.
val result = Tasks.await(segmenter.process(image))
val mask = result.foregroundConfidenceMask // This is a FloatBuffer
if (mask != null) {
val createdMask = createBitmapFromMask(mask, bitmap.width, bitmap.height)
maskCache[frame] = createdMask
createdMask
} else {
null
}
} catch (_: Exception) {
null
}
}

if (maskBitmap != null) {
val shader = RuntimeShader(MASK_SHADER)
val maskShader = BitmapShader(maskBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
shader.setInputShader("mask", maskShader)

view.setRenderEffect(RenderEffect.createRuntimeShaderEffect(shader, "content"))
}

return motionView
}

private fun createBitmapFromMask(
mask: FloatBuffer,
width: Int,
height: Int,
): Bitmap {
val bitmap = createBitmap(width, height)
mask.rewind()
val pixels = IntArray(width * height)
for (i in 0 until width * height) {
val confidence = if (mask.hasRemaining()) mask.get() else 0.0f
val alpha = (confidence * 255).toInt()
// Set alpha for the mask bitmap, color doesn't strictly matter if we only use alpha in shader
pixels[i] = Color.argb(alpha, 255, 255, 255)
}
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
}
Loading
Loading