Skip to content

Commit 819d339

Browse files
committed
feat(core): add an API to take a snapshot
1 parent 035e0e2 commit 819d339

File tree

7 files changed

+279
-11
lines changed

7 files changed

+279
-11
lines changed

core/src/main/java/io/github/thibaultbee/streampack/core/elements/processing/video/DefaultSurfaceProcessor.kt

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
1+
/*
2+
* Copyright 2022 The Android Open Source Project
3+
* Copyright 2025 Thibault B.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
117
package io.github.thibaultbee.streampack.core.elements.processing.video
218

19+
import android.graphics.Bitmap
320
import android.graphics.SurfaceTexture
421
import android.util.Size
522
import android.view.Surface
23+
import androidx.annotation.IntRange
624
import androidx.concurrent.futures.CallbackToFutureAdapter
725
import com.google.common.util.concurrent.ListenableFuture
826
import io.github.thibaultbee.streampack.core.elements.processing.video.outputs.ISurfaceOutput
927
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils
28+
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.extensions.preRotate
29+
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.extensions.preVerticalFlip
1030
import io.github.thibaultbee.streampack.core.elements.utils.av.video.DynamicRangeProfile
31+
import io.github.thibaultbee.streampack.core.elements.utils.extensions.rotate
1132
import io.github.thibaultbee.streampack.core.logger.Logger
1233
import io.github.thibaultbee.streampack.core.pipelines.DispatcherProvider.Companion.THREAD_NAME_GL
1334
import io.github.thibaultbee.streampack.core.pipelines.IVideoDispatcherProvider
1435
import io.github.thibaultbee.streampack.core.pipelines.utils.HandlerThreadExecutor
36+
import java.io.IOException
1537
import java.util.concurrent.atomic.AtomicBoolean
1638

1739

@@ -21,6 +43,8 @@ private class DefaultSurfaceProcessor(
2143
) : ISurfaceProcessorInternal, SurfaceTexture.OnFrameAvailableListener {
2244
private val renderer = OpenGlRenderer()
2345

46+
private val glHandler = glThread.handler
47+
2448
private val isReleaseRequested = AtomicBoolean(false)
2549
private var isReleased = false
2650

@@ -31,7 +55,7 @@ private class DefaultSurfaceProcessor(
3155
private val surfaceInputs: MutableList<SurfaceInput> = mutableListOf()
3256
private val surfaceInputsTimestampInNsMap: MutableMap<SurfaceTexture, Long> = hashMapOf()
3357

34-
private val glHandler = glThread.handler
58+
private val pendingSnapshots = mutableListOf<PendingSnapshot>()
3559

3660
init {
3761
val future = submitSafely {
@@ -177,6 +201,19 @@ private class DefaultSurfaceProcessor(
177201
}
178202
}
179203

204+
override fun snapshot(
205+
@IntRange(from = 0, to = 359) rotationDegrees: Int
206+
): ListenableFuture<Bitmap> {
207+
if (isReleaseRequested.get()) {
208+
throw IllegalStateException("SurfaceProcessor is released")
209+
}
210+
return CallbackToFutureAdapter.getFuture { completer ->
211+
executeSafely {
212+
pendingSnapshots.add(PendingSnapshot(rotationDegrees, completer))
213+
}
214+
}
215+
}
216+
180217
// Executed on GL thread
181218
override fun onFrameAvailable(surfaceTexture: SurfaceTexture) {
182219
if (isReleaseRequested.get()) {
@@ -201,6 +238,78 @@ private class DefaultSurfaceProcessor(
201238
Logger.e(TAG, "Error while rendering frame", t)
202239
}
203240
}
241+
242+
// Surface, size and transform matrix for JPEG Surface if exists
243+
if (pendingSnapshots.isNotEmpty()) {
244+
try {
245+
val first = surfaceOutputs.first()
246+
val snapshotOutput = Pair(
247+
first.descriptor.resolution,
248+
surfaceOutputMatrix.clone()
249+
)
250+
251+
// Execute all pending snapshots.
252+
takeSnapshot(snapshotOutput)
253+
} catch (e: RuntimeException) {
254+
// Propagates error back to the app if failed to take snapshot.
255+
failAllPendingSnapshots(e)
256+
}
257+
}
258+
}
259+
260+
/**
261+
* Takes a snapshot of the current frame and draws it to given JPEG surface.
262+
*
263+
* @param snapshotOutput The <Surface size, transform matrix> pair for drawing.
264+
*/
265+
private fun takeSnapshot(snapshotOutput: Pair<Size, FloatArray>) {
266+
if (pendingSnapshots.isEmpty()) {
267+
// No pending snapshot requests, do nothing.
268+
return
269+
}
270+
271+
// Write to JPEG surface, once for each snapshot request.
272+
try {
273+
for (pendingSnapshot in pendingSnapshots) {
274+
val (size, transform) = snapshotOutput
275+
276+
// Take a snapshot of the current frame.
277+
val bitmap = getBitmap(size, transform, pendingSnapshot.rotationDegrees)
278+
279+
// Complete the snapshot request.
280+
pendingSnapshot.completer.set(bitmap)
281+
}
282+
pendingSnapshots.clear()
283+
} catch (e: IOException) {
284+
failAllPendingSnapshots(e)
285+
}
286+
}
287+
288+
private fun failAllPendingSnapshots(throwable: Throwable) {
289+
for (pendingSnapshot in pendingSnapshots) {
290+
pendingSnapshot.completer.setException(throwable)
291+
}
292+
pendingSnapshots.clear()
293+
}
294+
295+
private fun getBitmap(
296+
size: Size,
297+
textureTransform: FloatArray,
298+
rotationDegrees: Int
299+
): Bitmap {
300+
val snapshotTransform = textureTransform.clone()
301+
302+
// Rotate the output if requested.
303+
snapshotTransform.preRotate(rotationDegrees.toFloat(), 0.5f, 0.5f)
304+
305+
// Flip the snapshot. This is for reverting the GL transform added in SurfaceOutputImpl.
306+
snapshotTransform.preVerticalFlip(0.5f)
307+
308+
// Update the size based on the rotation degrees.
309+
val rotatedSize = size.rotate(rotationDegrees)
310+
311+
// Take a snapshot Bitmap and compress it to JPEG.
312+
return renderer.snapshot(rotatedSize, snapshotTransform)
204313
}
205314

206315
private fun executeSafely(
@@ -242,6 +351,12 @@ private class DefaultSurfaceProcessor(
242351
}
243352

244353
private data class SurfaceInput(val surface: Surface, val surfaceTexture: SurfaceTexture)
354+
355+
private data class PendingSnapshot(
356+
@IntRange(from = 0, to = 359)
357+
val rotationDegrees: Int,
358+
val completer: CallbackToFutureAdapter.Completer<Bitmap>
359+
)
245360
}
246361

247362
class DefaultSurfaceProcessorFactory :

core/src/main/java/io/github/thibaultbee/streampack/core/elements/processing/video/ISurfaceProcessor.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
*/
1616
package io.github.thibaultbee.streampack.core.elements.processing.video
1717

18+
import android.graphics.Bitmap
1819
import android.util.Size
1920
import android.view.Surface
21+
import androidx.annotation.IntRange
22+
import com.google.common.util.concurrent.ListenableFuture
2023
import io.github.thibaultbee.streampack.core.elements.interfaces.Releasable
2124
import io.github.thibaultbee.streampack.core.elements.processing.video.outputs.ISurfaceOutput
2225
import io.github.thibaultbee.streampack.core.elements.utils.av.video.DynamicRangeProfile
@@ -45,6 +48,8 @@ interface ISurfaceProcessorInternal : ISurfaceProcessor, Releasable {
4548

4649
fun removeAllOutputSurfaces()
4750

51+
fun snapshot(@IntRange(from = 0, to = 359) rotationDegrees: Int): ListenableFuture<Bitmap>
52+
4853
/**
4954
* Factory interface for creating instances of [ISurfaceProcessorInternal].
5055
*/
@@ -54,4 +59,4 @@ interface ISurfaceProcessorInternal : ISurfaceProcessor, Releasable {
5459
dispatcherProvider: IVideoDispatcherProvider
5560
): ISurfaceProcessorInternal
5661
}
57-
}
62+
}

core/src/main/java/io/github/thibaultbee/streampack/core/elements/processing/video/OpenGlRenderer.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
package io.github.thibaultbee.streampack.core.elements.processing.video
1818

19+
import android.graphics.Bitmap
1920
import android.graphics.Rect
2021
import android.opengl.EGL14
2122
import android.opengl.EGLConfig
@@ -29,10 +30,13 @@ import android.util.Log
2930
import android.util.Size
3031
import android.view.Surface
3132
import androidx.annotation.WorkerThread
33+
import androidx.core.graphics.createBitmap
3234
import androidx.core.util.Pair
35+
import io.github.thibaultbee.streampack.core.elements.processing.video.outputs.SurfaceOutput
3336
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.EMPTY_ATTRIBS
3437
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.InputFormat
3538
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.NO_OUTPUT_SURFACE
39+
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.PIXEL_STRIDE
3640
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.Program2D
3741
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.SamplerShaderProgram
3842
import io.github.thibaultbee.streampack.core.elements.processing.video.utils.GLUtils.checkEglErrorOrLog
@@ -58,6 +62,7 @@ import java.nio.ByteBuffer
5862
import java.util.concurrent.atomic.AtomicBoolean
5963
import javax.microedition.khronos.egl.EGL10
6064

65+
6166
/**
6267
* OpenGLRenderer renders texture image to the output surface.
6368
*
@@ -310,6 +315,29 @@ class OpenGlRenderer {
310315
}
311316
}
312317

318+
/**
319+
* Takes a snapshot of the current external texture and returns a Bitmap.
320+
*
321+
* @param size the size of the output [Bitmap].
322+
* @param textureTransform the transformation matrix.
323+
* See: [SurfaceOutput.updateTransformMatrix]
324+
*/
325+
fun snapshot(size: Size, textureTransform: FloatArray): Bitmap {
326+
// Allocate buffer.
327+
val byteBuffer = ByteBuffer.allocateDirect(
328+
size.width * size.height * PIXEL_STRIDE
329+
)
330+
331+
// Take a snapshot.
332+
snapshot(byteBuffer, size, textureTransform)
333+
byteBuffer.rewind()
334+
335+
// Create a Bitmap and copy the bytes over.
336+
val bitmap = createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888)
337+
bitmap.copyPixelsFromBuffer(byteBuffer)
338+
return bitmap
339+
}
340+
313341
/**
314342
* Takes a snapshot of the current external texture and stores it in the given byte buffer.
315343
*

core/src/main/java/io/github/thibaultbee/streampack/core/pipelines/inputs/VideoInput.kt

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package io.github.thibaultbee.streampack.core.pipelines.inputs
1717

1818
import android.content.Context
19+
import android.graphics.Bitmap
1920
import android.view.Surface
21+
import androidx.annotation.IntRange
2022
import io.github.thibaultbee.streampack.core.elements.processing.video.ISurfaceProcessorInternal
2123
import io.github.thibaultbee.streampack.core.elements.processing.video.outputs.ISurfaceOutput
2224
import io.github.thibaultbee.streampack.core.elements.processing.video.source.ISourceInfoProvider
@@ -40,7 +42,12 @@ import kotlinx.coroutines.launch
4042
import kotlinx.coroutines.sync.Mutex
4143
import kotlinx.coroutines.sync.withLock
4244
import kotlinx.coroutines.withContext
45+
import java.io.File
46+
import java.io.FileOutputStream
47+
import java.io.OutputStream
4348
import java.util.concurrent.atomic.AtomicBoolean
49+
import kotlin.coroutines.resume
50+
import kotlin.coroutines.suspendCoroutine
4451

4552
/**
4653
* The public interface for the video input.
@@ -75,6 +82,64 @@ interface IVideoInput {
7582
* The video processor for adding effects to the video frames.
7683
*/
7784
val processor: ISurfaceProcessorInternal
85+
86+
/**
87+
* Takes a snapshot of the current video frame.
88+
*
89+
* The snapshot is returned as a [Bitmap].
90+
*
91+
* @param rotationDegrees The rotation to apply to the snapshot, in degrees. 0 means no rotation.
92+
* @return The snapshot as a [Bitmap].
93+
*/
94+
suspend fun takeSnapshot(@IntRange(from = 0, to = 359) rotationDegrees: Int = 0): Bitmap
95+
}
96+
97+
/**
98+
* Takes a JPEG snapshot of the current video frame.
99+
*
100+
* The snapshot is saved to the specified file.
101+
*
102+
* @param filePathString The path of the file to save the snapshot to.
103+
* @param quality The quality of the JPEG, from 0 to 100.
104+
* @param rotationDegrees The rotation to apply to the snapshot, in degrees.
105+
*/
106+
suspend fun IVideoInput.takeJpegSnapshot(
107+
filePathString: String,
108+
@IntRange(from = 0, to = 100) quality: Int = 100,
109+
@IntRange(from = 0, to = 359) rotationDegrees: Int = 0
110+
) = takeJpegSnapshot(FileOutputStream(filePathString), quality, rotationDegrees)
111+
112+
113+
/**
114+
* Takes a JPEG snapshot of the current video frame.
115+
*
116+
* The snapshot is saved to the specified file.
117+
*
118+
* @param file The file to save the snapshot to.
119+
* @param quality The quality of the JPEG, from 0 to 100.
120+
* @param rotationDegrees The rotation to apply to the snapshot, in degrees.
121+
*/
122+
suspend fun IVideoInput.takeJpegSnapshot(
123+
file: File,
124+
@IntRange(from = 0, to = 100) quality: Int = 100,
125+
@IntRange(from = 0, to = 359) rotationDegrees: Int = 0
126+
) = takeJpegSnapshot(FileOutputStream(file), quality, rotationDegrees)
127+
128+
/**
129+
* Takes a snapshot of the current video frame.
130+
*
131+
* The snapshot is saved as a JPEG to the specified output stream.
132+
* @param outputStream The output stream to save the snapshot to.
133+
* @param quality The quality of the JPEG, from 0 to 100.
134+
* @param rotationDegrees The rotation to apply to the snapshot, in degrees.
135+
*/
136+
suspend fun IVideoInput.takeJpegSnapshot(
137+
outputStream: OutputStream,
138+
@IntRange(from = 0, to = 100) quality: Int = 100,
139+
@IntRange(from = 0, to = 359) rotationDegrees: Int = 0
140+
) {
141+
val bitmap = takeSnapshot(rotationDegrees)
142+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
78143
}
79144

80145
/**
@@ -349,7 +414,25 @@ internal class VideoInput(
349414
return newSurfaceProcessor
350415
}
351416

352-
suspend fun addOutputSurface(output: ISurfaceOutput) {
417+
override suspend fun takeSnapshot(@IntRange(from = 0, to = 359) rotationDegrees: Int): Bitmap {
418+
if (isReleaseRequested.get()) {
419+
throw IllegalStateException("Input is released")
420+
}
421+
return withContext(dispatcherProvider.default) {
422+
suspendCoroutine { continuation ->
423+
val listener = processor.snapshot(rotationDegrees)
424+
try {
425+
val bitmap = listener.get()
426+
continuation.resume(bitmap)
427+
} catch (e: Exception) {
428+
continuation.resumeWith(Result.failure(e))
429+
}
430+
}
431+
}
432+
}
433+
434+
435+
internal suspend fun addOutputSurface(output: ISurfaceOutput) {
353436
if (isReleaseRequested.get()) {
354437
throw IllegalStateException("Input is released")
355438
}
@@ -360,7 +443,7 @@ internal class VideoInput(
360443
}
361444
}
362445

363-
suspend fun removeOutputSurface(output: Surface) {
446+
internal suspend fun removeOutputSurface(output: Surface) {
364447
outputMutex.withLock {
365448
surfaceOutput.firstOrNull { it.descriptor.surface == output }?.let {
366449
surfaceOutput.remove(it)

0 commit comments

Comments
 (0)