Skip to content

Support parallel MotionViews and parallelized frame production in video adapters#31

Merged
tejpratap46 merged 1 commit into
mainfrom
codex/update-video-production-for-multi-threading-rf5vut
Apr 16, 2026
Merged

Support parallel MotionViews and parallelized frame production in video adapters#31
tejpratap46 merged 1 commit into
mainfrom
codex/update-video-production-for-multi-threading-rf5vut

Conversation

@tejpratap46

Copy link
Copy Markdown
Owner

Motivation

  • Allow producing video frames from multiple MotionView instances in parallel to speed up frame capture and better utilize CPU cores.
  • Ensure adapters can write frames safely and consistently when capturing from shared MotionView instances.

Description

  • Changed VideoProducerAdapter.produceVideo signature to accept motionComposerViews: List<MotionView> instead of a single MotionView and updated call sites accordingly.
  • Updated AndroidVideoProducerAdapter and FfmpegVideoProducerAdapter to split the total frames into worker chunks, launch concurrent coroutines (Dispatchers.Default) to capture, compress and save frames into a cache subdirectory, and await completion before encoding; added captureFrameBitmap helper that synchronizes on the MotionView during forFrame(...).getViewBitmap() to avoid concurrent access issues.
  • Updated JCodecVideoProducerAdapter to accept motionComposerViews and use the first view (preserving previous behavior) for encoding with AndroidSequenceEncoder.
  • Added error handling when saving frames (throwing IllegalStateException on failure), ensured cache subdirectory lifecycle (clear/create), and preserved progress callback invocation semantics.
  • Extended MotionVideoProducer to accept parallelMotionViews (and with(...) factory) and to pass either the provided parallel views or the single MotionComposerView into the adapter.

Testing

  • Performed a full Gradle build with ./gradlew build, which completed successfully.
  • Executed unit tests with ./gradlew test, and the test suite passed locally.

Codex Task

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@tejpratap46 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 25 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 9 minutes and 25 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 241ef0a3-9890-4199-a39c-c8a23c91caec

📥 Commits

Reviewing files that changed from the base of the PR and between 207d121 and 8ede58e.

📒 Files selected for processing (5)
  • modules/core/src/main/java/com/tejpratapsingh/motionlib/core/VideoProducerAdapter.kt
  • modules/ffmpeg-motion-ext/src/main/java/com/tejpratapsingh/motionlib/ffmpeg/FfmpegVideoProducerAdapter.kt
  • modules/jcodec-motion-ext/src/main/java/com/tejpratapsingh/motionlib/jcodec/JCodecVideoProducerAdapter.kt
  • modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/core/adapter/AndroidVideoProducerAdapter.kt
  • modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/core/motion/MotionVideoProducer.kt
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/update-video-production-for-multi-threading-rf5vut

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@amazon-q-developer amazon-q-developer Bot left a comment

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.

Summary

This PR introduces parallel video frame production across multiple MotionView instances to improve performance. The implementation successfully adds concurrent frame capture and processing using coroutines.

Critical Issues Found

I've identified 4 critical defects that must be addressed before merge:

  1. Chunk size calculation bug: When totalFrames < workerCount, some workers receive invalid empty frame ranges (startFrame > endFrame), causing incorrect processing or skipped frames
  2. Race condition in progress reporting: Multiple workers invoke progress callbacks concurrently without ordering, causing out-of-sequence progress updates that break progress tracking
  3. Shared lazy UUID across calls: The subDirName lazy property is reused across multiple produceVideo calls on the same adapter instance, creating race conditions and file conflicts
  4. Return deleted file on FFmpeg failure: When FFmpeg fails, the code deletes the output file but still returns it, causing crashes for callers expecting valid files

All issues have specific corrected code suggestions attached. Please review and apply the fixes.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

throw IllegalStateException("Unable to save frame $frame", e)
}

progressListener?.invoke(frame, frameBitmap)

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.

🛑 Race Condition: Concurrent progress callbacks without ordering. Multiple parallel workers invoke the progress listener concurrently (line 84), causing out-of-order progress reporting. Frame 100 could be reported before frame 50, breaking progress tracking. Use a thread-safe mechanism to sequence callbacks or document this behavior.

progressListener?.let {
it(i, frameBitmap)
}
progressListener?.invoke(frame, frameBitmap)

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.

🛑 Race Condition: Concurrent progress callbacks without ordering. Multiple parallel workers invoke the progress listener concurrently (line 84), causing out-of-order progress reporting. Frame 100 could be reported before frame 50, breaking progress tracking. Use a thread-safe mechanism to sequence callbacks or document this behavior.

Comment on lines +50 to +57
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = ((totalFrames + workerCount) - 1) / workerCount

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.

🛑 Logic Error: Chunk size calculation can create empty ranges. When totalFrames < workerCount, some workers get startFrame > endFrame. For example, with 2 frames and 4 workers, worker 2 gets startFrame=2, endFrame=1 (an invalid empty range). Adjust the calculation to prevent workers from having empty or invalid ranges.

Suggested change
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = ((totalFrames + workerCount) - 1) / workerCount
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = if (totalFrames < workerCount) 1 else ((totalFrames + workerCount) - 1) / workerCount

Comment on lines +51 to +58
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = ((totalFrames + workerCount) - 1) / workerCount

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.

🛑 Logic Error: Chunk size calculation can create empty ranges. When totalFrames < workerCount, some workers get startFrame > endFrame. For example, with 2 frames and 4 workers, worker 2 gets startFrame=2, endFrame=1 (an invalid empty range). Adjust the calculation to prevent workers from having empty or invalid ranges.

Suggested change
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = ((totalFrames + workerCount) - 1) / workerCount
val safeMotionComposerViews = motionComposerViews.ifEmpty { error("At least one MotionView is required") }
val workerCount =
minOf(
totalFrames.coerceAtLeast(1),
safeMotionComposerViews.size.coerceAtLeast(1),
Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
)
val chunkSize = if (totalFrames < workerCount) 1 else ((totalFrames + workerCount) - 1) / workerCount

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces parallel video frame processing by updating the VideoProducerAdapter to accept a list of MotionView objects and utilizing Kotlin coroutines for concurrent execution in the Ffmpeg and Android adapters. While this improves performance, several issues were identified regarding memory management; specifically, capturedBitmap and frameBitmap are not being recycled after use, which could lead to OutOfMemoryError. Furthermore, the progressListener is now invoked concurrently, necessitating thread-safe implementations in the calling code.

Comment on lines +68 to +70
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The capturedBitmap returned by captureFrameBitmap is not recycled after being compressed. Since video production can involve hundreds of frames, failing to recycle these bitmaps will lead to rapid memory exhaustion and potential OutOfMemoryError. It should be recycled immediately after compressToBitmap is called.

Suggested change
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)
capturedBitmap.recycle()

Comment on lines +73 to +84
try {
context.saveBitmapToCacheFolder(
frameBitmap,
subDirName,
String.format(Locale.getDefault(), "%05d.png", frame),
)
} catch (e: Exception) {
Log.e(TAG, "Error saving frame $frame: ${e.message}", e)
throw IllegalStateException("Unable to save frame $frame", e)
}

progressListener?.invoke(frame, frameBitmap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The frameBitmap should be recycled to free up memory after it has been saved and passed to the progress listener. Additionally, please be aware that progressListener is now invoked concurrently from multiple coroutines. If the listener implementation is not thread-safe (e.g., it updates a UI component or a non-atomic counter), this could lead to race conditions or crashes. Consider documenting this requirement or synchronizing the calls.

                        try {
                            context.saveBitmapToCacheFolder(
                                frameBitmap,
                                subDirName,
                                String.format(Locale.getDefault(), "%05d.png", frame),
                            )
                        } catch (e: Exception) {
                            Log.e(TAG, "Error saving frame $frame: ${e.message}", e)
                            frameBitmap.recycle()
                            throw IllegalStateException("Unable to save frame $frame", e)
                        }

                        progressListener?.invoke(frame, frameBitmap)
                        frameBitmap.recycle()

Comment on lines +69 to +71
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The capturedBitmap is not recycled after the compressed version is created. In a parallelized environment producing many frames, this will significantly increase memory pressure. It should be recycled as soon as it is no longer needed.

Suggested change
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)
val capturedBitmap = captureFrameBitmap(motionComposerView, frame)
val frameBitmap: Bitmap =
capturedBitmap.compressToBitmap(motionConfig.outputQuality)
capturedBitmap.recycle()

Comment on lines +73 to +84
try {
context.saveBitmapToCacheFolder(
frameBitmap,
subDirName,
String.format(Locale.getDefault(), "%05d.png", frame),
)
} catch (e: Exception) {
Log.e(TAG, "Error saving frame $frame: ${e.message}", e)
throw IllegalStateException("Unable to save frame $frame", e)
}

progressListener?.let {
it(i, frameBitmap)
}
progressListener?.invoke(frame, frameBitmap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To prevent memory leaks, frameBitmap must be recycled after use. Also, note that the progressListener is called concurrently across different worker threads. Ensure that any implementation of this listener is thread-safe to avoid synchronization issues.

                        try {
                            context.saveBitmapToCacheFolder(
                                frameBitmap,
                                subDirName,
                                String.format(Locale.getDefault(), "%05d.png", frame),
                            )
                        } catch (e: Exception) {
                            Log.e(TAG, "Error saving frame $frame: ${e.message}", e)
                            frameBitmap.recycle()
                            throw IllegalStateException("Unable to save frame $frame", e)
                        }

                        progressListener?.invoke(frame, frameBitmap)
                        frameBitmap.recycle()

@tejpratap46 tejpratap46 merged commit 4fea635 into main Apr 16, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant