JetBrains / skiko

Kotlin Multiplatform bindings to Skia
Apache License 2.0
1.79k stars 110 forks source link

Picture can lose content after serialization/deserialization #966

Open JB-Dmitry opened 1 month ago

JB-Dmitry commented 1 month ago

Reproducer:

import org.jetbrains.skia.*
import java.nio.file.*

private const val IMAGE_SIZE = 50
private const val SQUARE_SIZE = 15
private val PICTURE_BOUNDS = Rect.makeLTRB(-1e9f, -1e9f, 1e9f, 1e9f)

fun main() {
    val picture = PictureRecorder().use { recorder ->
        val canvas = recorder.beginRecording(PICTURE_BOUNDS)
        val square = Rect.makeWH(SQUARE_SIZE.toFloat(), SQUARE_SIZE.toFloat())
        canvas.clipRect(square)
        canvas.drawRect(square, Paint().apply { color = Color.RED })
        recorder.finishRecordingAsPicture()
    }
    renderPicture(picture, "direct.png")
    val reconstructedPicture = Picture.makeFromData(picture.serializeToData())!!
    renderPicture(reconstructedPicture, "reconstructed.png")
}

private fun renderPicture(picture: Picture, fileName: String) {
    Surface.makeRasterN32Premul(IMAGE_SIZE, IMAGE_SIZE).use { surface ->
        surface.canvas.clear(Color.WHITE)
        surface.canvas.drawPicture(picture)
        val imageBytes = surface.makeImageSnapshot().encodeToData()!!.use { it.bytes }
        Files.write(Paths.get(fileName), imageBytes)
    }
}

Expected result: both direct.png and reconstructed.png image files have a red square on white background. Actual result: reconstructed.png image file doesn't contain the red square.

Reproduced on macOS 14.5 (M2 Max) for Skiko (Kotlin/JVM) versions 0.8.4 and 0.8.10.

Note, that increasing the SQUARE_SIZE value up to 33 or more makes the red rectangle appear in reconstructed.png.

The problem is probably related to the cull rect, used for picture recording, being too large, so that a numerical overflow is happening somewhere in the serialization/deserialization code. Pictures with such a big cull rect are actually used in Compose code (to cache the intermediate rendering results, AFAIU), and the problem was initially observed in relation to Compose code. Here's the corresponding reproducer:

import androidx.compose.material.TextField
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.scene.MultiLayerComposeScene
import org.jetbrains.skia.*
import java.nio.file.Files
import java.nio.file.Paths

@OptIn(InternalComposeUiApi::class)
fun main() {
    val scene = MultiLayerComposeScene()
    scene.setContent {
        TextField(value = "0", onValueChange = {})
    }
    val picture = PictureRecorder().use { recorder ->
        val canvas = recorder.beginRecording(Rect.makeWH(100f, 100f))
        scene.render(canvas.asComposeCanvas(), 0)
        recorder.finishRecordingAsPicture()
    }
    renderPicture(picture, "direct.png")
    val reconstructedPicture = Picture.makeFromData(picture.serializeToData())!!
    renderPicture(reconstructedPicture, "reconstructed.png")
}

private fun renderPicture(picture: Picture, fileName: String) {
    val bounds = picture.cullRect
    val surface = Surface.makeRasterN32Premul(bounds.width.toInt(), bounds.height.toInt())
    surface.canvas.clear(Color.WHITE)
    surface.canvas.drawPicture(picture)
    val imageBytes = surface.makeImageSnapshot().encodeToData()!!.bytes
    Files.write(Paths.get(fileName), imageBytes)
}

It's expected to see '0' rendered in both images, but it's absent in reconstructed.png. The problem was reproduced with Compose Desktop version 1.6.11.

JB-Dmitry commented 1 month ago

The problem seems to be in Skia, not in Skiko's bindings. Here's the 'fiddle' that reproduces the problem: https://fiddle.skia.org/c/831a2f73004d056f1a422e5d3b053e23 And its code, for reference:

void draw(SkCanvas* canvas) {
    SkPictureRecorder recorder;
    SkCanvas* pictureCanvas = recorder.beginRecording({-1e9, -1e9, 1e9, 1e9});
    SkRect r = SkRect::MakeWH(15, 15);
    pictureCanvas->clipRect(r);
    SkPaint p;
    p.setColor(SK_ColorRED);
    pictureCanvas->drawRect(r, p);
    sk_sp<SkPicture> picture = recorder.finishRecordingAsPicture();
    SkSerialProcs sProcs;
    sk_sp<SkData> readableData = picture->serialize();
    sk_sp<SkPicture> restored = SkPicture::MakeFromData(readableData->data(), readableData->size());
    restored->playback(canvas);
}