Skip to content

Commit

Permalink
Support Paparazzi#gif with APNGs
Browse files Browse the repository at this point in the history
  • Loading branch information
gamepro65 committed Feb 1, 2024
1 parent 5ed5b65 commit 58862a2
Show file tree
Hide file tree
Showing 27 changed files with 797 additions and 157 deletions.
4 changes: 0 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ androidTools = "31.2.2" # == 23.0.0 + agp version
bytebuddy = "1.14.11"
composeCompiler = "1.5.8"
javaTarget = "11"
jcodec = "0.2.5"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.17"
ktlint = "0.50.0"
Expand Down Expand Up @@ -33,9 +32,6 @@ composeUi-uiTooling = { module = "androidx.compose.ui:ui-tooling" }
grgit = { module = "org.ajoberstar.grgit:grgit-core", version = "5.2.1" }
guava = { module = "com.google.guava:guava", version = "33.0.0-jre" }

jcodec-core = { module = "org.jcodec:jcodec", version.ref = "jcodec" }
jcodec-javase = { module = "org.jcodec:jcodec-javase", version.ref = "jcodec" }

kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.7.3" }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import java.io.File
import java.nio.file.Files

class PaparazziPluginTest {
private val filesToDelete = mutableListOf<File>()
Expand Down Expand Up @@ -1197,6 +1196,18 @@ class PaparazziPluginTest {
assertThat(snapshotImage).isSimilarTo(goldenImage).withDefaultThreshold()
}

@Test
fun verifyGif() {
val fixtureRoot = File("src/test/projects/verify-gif")

val result = gradleRunner
.withArguments("verifyPaparazzi", "--stacktrace")
.runFixture(fixtureRoot) { build() }

assertThat(result.task(":preparePaparazziDebugResources")).isNotNull()
assertThat(result.task(":testDebugUnitTest")).isNotNull()
}

@Test
fun verifyVectorDrawables() {
val fixtureRoot = File("src/test/projects/verify-svgs")
Expand Down Expand Up @@ -1240,14 +1251,11 @@ class PaparazziPluginTest {
.runFixture(fixtureRoot) { build() }

val snapshotsDir = File(fixtureRoot, "build/reports/paparazzi/debug/images")
val snapshotFile = File(snapshotsDir, "9d3c31a9c79a363c26dc352b59e9e77083c300a7.png")
val snapshotFile = File(snapshotsDir, "a2095a4d9f78335277b48a4bda336c1437d44295.png")
assertThat(snapshotFile.exists()).isTrue()

val goldenImage = File(fixtureRoot, "src/test/resources/arrow_missing.png")
val actualFileBytes = Files.readAllBytes(snapshotFile.toPath())
val expectedFileBytes = Files.readAllBytes(goldenImage.toPath())

assertThat(actualFileBytes).isEqualTo(expectedFileBytes)
assertThat(goldenImage).isSimilarTo(snapshotFile).withDefaultThreshold()
}

@Test
Expand Down
22 changes: 22 additions & 0 deletions paparazzi-gradle-plugin/src/test/projects/verify-gif/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'app.cash.paparazzi'
}

android {
namespace 'app.cash.paparazzi.plugin.test'
compileSdk libs.versions.compileSdk.get() as int
defaultConfig {
minSdk libs.versions.minSdk.get() as int
}
compileOptions {
sourceCompatibility = libs.versions.javaTarget.get()
targetCompatibility = libs.versions.javaTarget.get()
}
kotlinOptions {
jvmTarget = libs.versions.javaTarget.get()
}
}

apply from: '../guava-fix.gradle'
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bolt"
android:padding="30dp"
>

<TextView
android:id="@+id/amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=".01 BTC"
android:textSize="70dp"
android:gravity="center"
android:textColor="@color/keypadDarkGrey"
android:textFontWeight="100"
android:layout_weight="2"
/>

<TextView
android:id="@+id/amount123"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1 2 3"
android:textSize="45dp"
android:gravity="center"
android:textFontWeight="200"
android:textColor="@color/keypadDarkGrey"
android:layout_weight="1"
/>
<TextView
android:id="@+id/amount456"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="4 5 6"
android:textFontWeight="400"
android:textSize="45dp"
android:gravity="center"
android:textColor="@color/keypadDarkGrey"
android:layout_weight="1"
/>
<TextView
android:id="@+id/amount789"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="7 8 9"
android:textFontWeight="600"
android:textSize="45dp"
android:gravity="center"
android:textColor="@color/keypadDarkGrey"
android:layout_weight="1"
/>
<TextView
android:id="@+id/amount0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="# 0     . "
android:textSize="45dp"
android:textFontWeight="800"
android:gravity="center"
android:textColor="@color/keypadDarkGrey"
android:layout_weight="1"
/>

</LinearLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bolt">#EBFF00</color>
<color name="keypadDarkGrey">#595959</color>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.paparazzi.plugin.test

import android.animation.ObjectAnimator
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test

class KeypadViewTest {
@get:Rule
val paparazzi = Paparazzi(showSystemUi = true)

@Test
fun testViews() {
val keypad = paparazzi.inflate<LinearLayout>(R.layout.keypad)
val amount = keypad.findViewById<TextView>(R.id.amount)

val rotation = ObjectAnimator.ofFloat(amount, View.ROTATION, 0.0f, 360.0f).apply {
duration = 500
startDelay = 500
}
rotation.start()

paparazzi.gif(keypad, "spin", start = 500, end = 1500, fps = 30)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 0 additions & 2 deletions paparazzi/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ dependencies {
implementation libs.moshi.core
implementation libs.moshi.adapters
implementation libs.moshi.kotlinReflect
implementation libs.jcodec.core
implementation libs.jcodec.javase

def osName = System.getProperty("os.name").toLowerCase(Locale.US)
if (osName.startsWith("mac")) {
Expand Down
86 changes: 17 additions & 69 deletions paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ package app.cash.paparazzi

import app.cash.paparazzi.SnapshotHandler.FrameHandler
import app.cash.paparazzi.internal.PaparazziJson
import app.cash.paparazzi.internal.apng.ApngWriter
import com.google.common.base.CharMatcher
import okio.BufferedSink
import okio.HashingSink
import okio.Path.Companion.toPath
import okio.blackholeSink
import okio.buffer
import okio.sink
import okio.source
import org.jcodec.api.awt.AWTSequenceEncoder
import java.awt.image.BufferedImage
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import javax.imageio.ImageIO

/**
* Creates an HTML report that avoids writing files that have already been written.
Expand Down Expand Up @@ -89,60 +89,34 @@ public class HtmlReportWriter @JvmOverloads constructor(
fps: Int
): FrameHandler {
return object : FrameHandler {
val snapshotDir = if (fps == -1) imagesDirectory else videosDirectory
val goldenDir = if (fps == -1) goldenImagesDirectory else goldenVideosDirectory
val hashes = mutableListOf<String>()
val file = File(snapshotDir, snapshot.toFileName(extension = "temp.png"))
val writer = ApngWriter(file.path.toPath(), fps)

override fun handle(image: BufferedImage) {
hashes += writeImage(image)
hashes += hash(image)
writer.writeImage(image)
}

override fun close() {
if (hashes.isEmpty()) return

val shot = if (hashes.size == 1) {
val original = File(imagesDirectory, "${hashes[0]}.png")
if (isRecording) {
val goldenFile = File(goldenImagesDirectory, snapshot.toFileName("_", "png"))
original.copyTo(goldenFile, overwrite = true)
}
snapshot.copy(file = original.toJsonPath())
} else {
val hash = writeVideo(hashes, fps)

if (isRecording) {
for ((index, frameHash) in hashes.withIndex()) {
val originalFrame = File(imagesDirectory, "$frameHash.png")
val frameSnapshot = snapshot.copy(name = "${snapshot.name} $index")
val goldenFile = File(goldenImagesDirectory, frameSnapshot.toFileName("_", "png"))
if (!goldenFile.exists()) {
originalFrame.copyTo(goldenFile)
}
}
}
val original = File(videosDirectory, "$hash.mov")
if (isRecording) {
val goldenFile = File(goldenVideosDirectory, snapshot.toFileName("_", "mov"))
if (!goldenFile.exists()) {
original.copyTo(goldenFile)
}
}
snapshot.copy(file = original.toJsonPath())
writer.close()
val hashedFile = File(snapshotDir, "${hash(hashes)}.png")
file.renameTo(hashedFile)
file.delete()

if (isRecording) {
val goldenFile = File(goldenDir, snapshot.toFileName("_", "png"))
hashedFile.copyTo(target = goldenFile, overwrite = true)
}

shots += shot
shots += snapshot.copy(file = hashedFile.toJsonPath())
}
}
}

/** Returns the hash of the image. */
private fun writeImage(image: BufferedImage): String {
val hash = hash(image)
val file = File(imagesDirectory, "$hash.png")
if (!file.exists()) {
file.writeAtomically(image)
}
return hash
}

/** Returns a SHA-1 hash of the pixels of [image]. */
private fun hash(image: BufferedImage): String {
val hashingSink = HashingSink.sha1(blackholeSink())
Expand All @@ -156,25 +130,6 @@ public class HtmlReportWriter @JvmOverloads constructor(
return hashingSink.hash.hex()
}

private fun writeVideo(
frameHashes: List<String>,
fps: Int
): String {
val hash = hash(frameHashes)
val file = File(videosDirectory, "$hash.mov")
if (!file.exists()) {
val tmpFile = File(videosDirectory, "$hash.mov.tmp")
val encoder = AWTSequenceEncoder.createSequenceEncoder(tmpFile, fps)
for (frameHash in frameHashes) {
val frame = ImageIO.read(File(imagesDirectory, "$frameHash.png"))
encoder.encodeImage(frame)
}
encoder.finish()
tmpFile.renameTo(file)
}
return hash
}

/** Returns a SHA-1 hash of [lines]. */
private fun hash(lines: List<String>): String {
val hashingSink = HashingSink.sha1(blackholeSink())
Expand Down Expand Up @@ -257,13 +212,6 @@ public class HtmlReportWriter @JvmOverloads constructor(
}
}

private fun File.writeAtomically(bufferedImage: BufferedImage) {
val tmpFile = File(parentFile, "$name.tmp")
ImageIO.write(bufferedImage, "PNG", tmpFile)
delete()
tmpFile.renameTo(this)
}

private fun File.writeAtomically(writerAction: BufferedSink.() -> Unit) {
val tmpFile = File(parentFile, "$name.tmp")
tmpFile.sink()
Expand Down
Loading

0 comments on commit 58862a2

Please sign in to comment.