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