1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package platform.test.motion.filmstrip
18 
19 import android.graphics.Bitmap
20 import android.graphics.Canvas
21 import android.graphics.Color
22 import android.graphics.Matrix
23 import android.graphics.Paint
24 import android.graphics.Rect
25 import kotlin.math.ceil
26 import kotlin.math.max
27 import kotlin.math.min
28 import platform.test.motion.golden.FrameId
29 
30 /** Concatenates animation screenshots into a filmstrip image. */
31 class Filmstrip(private val screenshots: List<MotionScreenshot>) {
32     init {
<lambda>null33         require(screenshots.isNotEmpty()) { "Filmstrip must have at least one screenshot" }
34     }
35 
36     /** Direction in which to concatenate the frames. */
37     var orientation: FilmstripOrientation = FilmstripOrientation.AUTOMATIC
38 
<lambda>null39     private var screenshotWidth = screenshots.maxOf { it.bitmap.width }
<lambda>null40     private var screenshotHeight = screenshots.maxOf { it.bitmap.height }
41 
42     /**
43      * Scales down the screenshots, so that to the longer side will be at most [sizePx].
44      *
45      * All screenshots are scaled uniformly, the scale factor is determined by the largest
46      * width/height of all screenshots.
47      *
48      * Aspect ratio of each screenshot is retained. Has no effect is [sizePx] is equal or larger
49      * than the largest screenshot.
50      */
limitLongestSidenull51     fun limitLongestSide(sizePx: Int) {
52         val aspectRatio = screenshotWidth / screenshotHeight.toFloat()
53         if (aspectRatio >= 1 && sizePx < screenshotWidth) {
54             screenshotHeight = (screenshotHeight * (sizePx.toFloat() / screenshotWidth)).toInt()
55             screenshotWidth = sizePx
56         } else if (aspectRatio < 1 && sizePx < screenshotHeight) {
57             screenshotWidth = (screenshotWidth * (sizePx.toFloat() / screenshotHeight)).toInt()
58             screenshotHeight = sizePx
59         }
60     }
61 
62     /** Draws the screenshots into a new filmstrip [Bitmap]. */
renderFilmstripnull63     fun renderFilmstrip(): Bitmap {
64         check(screenshots.isNotEmpty()) { "Filmstrip can only be rendered with screenshots" }
65         return filmstripRenderer.render()
66     }
67 
68     private val filmstripRenderer: FilmstripRenderer
69         get() {
70             val isHorizontal =
71                 when (orientation) {
72                     FilmstripOrientation.HORIZONTAL -> true
73                     FilmstripOrientation.VERTICAL -> false
74                     FilmstripOrientation.AUTOMATIC -> screenshotWidth <= screenshotHeight
75                 }
76 
<lambda>null77             val scaleX = screenshotWidth.toFloat() / screenshots.maxOf { it.bitmap.width }
<lambda>null78             val scaleY = screenshotHeight.toFloat() / screenshots.maxOf { it.bitmap.height }
79             val scale = min(scaleX, scaleY).coerceAtMost(1f)
80 
81             return if (isHorizontal) {
82                 HorizontalFilmstripRenderer(screenshots, screenshotWidth, screenshotHeight, scale)
83             } else {
84                 VerticalFilmstripRenderer(screenshots, screenshotWidth, screenshotHeight, scale)
85             }
86         }
87 }
88 
89 /** Orientation in which screenshots are stitched together to a filmstrip. */
90 enum class FilmstripOrientation {
91     /** Horizontal for screenshots taller than wide, and vice versa */
92     AUTOMATIC,
93     HORIZONTAL,
94     VERTICAL
95 }
96 
97 /** An animation screenshot annotated with the frame its originating from. */
98 data class MotionScreenshot(val frameId: FrameId, val bitmap: Bitmap)
99 
100 private sealed class FilmstripRenderer(
101     val screenshots: List<MotionScreenshot>,
102     val screenshotWidth: Int,
103     val screenshotHeight: Int,
104     val scale: Float
105 ) {
106     val bitmapConfig = checkNotNull(screenshots.first().bitmap.config)
107 
<lambda>null108     val labels = screenshots.map { it.frameId.label }
109 
110     val backgroundPaint =
<lambda>null111         Paint().apply {
112             style = Paint.Style.FILL
113             color = Color.WHITE
114         }
115 
116     val textPaint =
<lambda>null117         Paint().apply {
118             style = Paint.Style.FILL
119             textSize = 20f
120             color = Color.BLACK
121         }
122 
123     val labelMargin = 5
<lambda>null124     val labelWidth = ceil(labels.map { textPaint.measureText(it) }.max()).toInt()
125     val labelHeight = ceil(textPaint.textSize).toInt()
126 
rendernull127     abstract fun render(): Bitmap
128 }
129 
130 private class HorizontalFilmstripRenderer(
131     screenshots: List<MotionScreenshot>,
132     screenshotWidth: Int,
133     screenshotHeight: Int,
134     scale: Float
135 ) : FilmstripRenderer(screenshots, screenshotWidth, screenshotHeight, scale) {
136 
137     init {
138         textPaint.textAlign = Paint.Align.CENTER
139     }
140 
141     override fun render(): Bitmap {
142         val tileWidth = max(screenshotWidth, labelWidth)
143 
144         val width = screenshots.size * tileWidth
145         val height = screenshotHeight + labelHeight + 2 * labelMargin
146 
147         val filmstrip = Bitmap.createBitmap(width, height, bitmapConfig)
148         val canvas = Canvas(filmstrip)
149 
150         // Background behind the labels
151         canvas.drawRect(
152             /* left = */ 0f,
153             /* top = */ screenshotHeight.toFloat(),
154             /* right = */ width.toFloat(),
155             /* bottom = */ height.toFloat(),
156             /* paint = */ backgroundPaint,
157         )
158 
159         val transform = Matrix()
160         var x = 0f
161         for ((screenshot, label) in screenshots.zip(labels)) {
162             val left = x + (tileWidth - screenshotWidth) / 2
163 
164             transform.reset()
165             transform.setTranslate(left, 0f)
166             transform.postScale(scale, scale, left, 0f)
167 
168             canvas.drawBitmap(
169                 /* bitmap = */ screenshot.bitmap,
170                 /* matrix = */ transform,
171                 /* paint = */ backgroundPaint
172             )
173 
174             canvas.drawText(
175                 /* text = */ label,
176                 /* x = */ x + tileWidth / 2,
177                 /* y = */ (screenshotHeight + labelMargin + labelHeight).toFloat(),
178                 /* paint = */ textPaint,
179             )
180 
181             x += tileWidth
182         }
183         return filmstrip
184     }
185 }
186 
187 private class VerticalFilmstripRenderer(
188     screenshots: List<MotionScreenshot>,
189     screenshotWidth: Int,
190     screenshotHeight: Int,
191     scale: Float
192 ) : FilmstripRenderer(screenshots, screenshotWidth, screenshotHeight, scale) {
rendernull193     override fun render(): Bitmap {
194         val tileHeight = max(screenshotHeight, labelHeight + 2 * labelMargin)
195 
196         val width = screenshotWidth + labelWidth + 2 * labelMargin
197         val height = screenshots.size * tileHeight
198 
199         val filmstrip = Bitmap.createBitmap(width, height, bitmapConfig)
200         val canvas = Canvas(filmstrip)
201 
202         canvas.drawRect(
203             /* left = */ screenshotWidth.toFloat(),
204             /* top = */ 0f,
205             /* right = */ width.toFloat(),
206             /* bottom = */ height.toFloat(),
207             /* paint = */ backgroundPaint,
208         )
209 
210         val transform = Matrix()
211         var y = 0f
212         for ((screenshot, label) in screenshots.zip(labels)) {
213             val top = y + (tileHeight - screenshotHeight) / 2
214             transform.reset()
215             transform.setTranslate(0f, top)
216             transform.postScale(scale, scale, 0f, top)
217 
218             canvas.drawBitmap(
219                 /* bitmap = */ screenshot.bitmap,
220                 /* matrix = */ transform,
221                 /* paint = */ backgroundPaint
222             )
223 
224             val textBounds = Rect()
225             textPaint.getTextBounds(label, 0, label.length, textBounds)
226             canvas.drawText(
227                 /* text = */ label,
228                 /* x = */ (screenshotWidth + labelMargin).toFloat(),
229                 /* y = */ y + (tileHeight + textBounds.height()) / 2,
230                 /* paint = */ textPaint,
231             )
232 
233             y += tileHeight
234         }
235         return filmstrip
236     }
237 }
238