Skip to content

StreamDiffusionView.kt

src/jvmMain/kotlin/afield/media/video/diffusion/StreamDiffusionView.kt · 253 lines

package afield.media.video.diffusion

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.RenderToTexture
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.*
import org.lwjgl.vulkan.VK13.*

A render target that sends a source RenderToTexture through the streaming-diffusion worker and returns the result as an ordinary sampled texture, StreamDiffusionView.output.

The frames stay on the GPU throughout: the source is copied into shared buffers, the CUDA worker writes its result into others, and the view copies that back into output. Construct and close a view between frames. With renderSource = true the view schedules the source itself; with false the caller renders the source earlier in the same command buffer. An optional StreamDiffusionMotionProvider enables display reprojection and history blending.

class StreamDiffusionView

GPU RTT -> optional CUDA worker -> ordinary sampled RTT. Construct/close between frames. With renderSource=true this target owns scheduling of source; do not also register source. With renderSource=false the caller renders source earlier in the same command buffer.

class StreamDiffusionView(
    /** The renderer whose graphics queue hands the frames over, and whose cleanup frees them. */
    val renderer: Renderer,
    /** What is stylized: a single-layer, single-sample, transfer-source RGBA8 or BGRA8 target,
     * with both dimensions multiples of 32 for the diffusion backend. */
    val source: RenderToTexture,
    /** Where the backend is and how it draws. */
    val config: StreamDiffusionConfig,
    /** The view's key in the renderer's pre-target list; two views need two names. */
    val name: String = "stream-diffusion",
    /** Whether this view draws [StreamDiffusionView.source] itself. Pass false, and render it earlier in the same
     * command buffer, when something else already schedules it. */
    val renderSource: Boolean = true,
    /** The clock stamped on each captured frame and returned with its result, for a caller whose
     * source does not advance in real time. */
    val sourceTimeNanos: () -> Long = System::nanoTime,
    /** Per-pixel correspondence from the renderer, which is what display reprojection, history
     * blending and noise advection need; without one the worker's output is published as it is. */
    val motion: StreamDiffusionMotionProvider? = null
) : Target, AutoCloseable {

val output: RenderToTexture

The texture to sample: an ordinary sampled target holding the newest stylized frame.

val session: StreamDiffusionSession

The worker process behind this view, with its status, readiness and timings.

    private val rawOutput: RenderToTexture
    private val temporal: StreamDiffusionTemporalOutput?

var displayReprojection

Warp the newest result onto the current frame before showing it, so the picture follows the geometry while the worker is still behind. Needs StreamDiffusionView.motion.

    @Volatile
    var displayReprojection = config.displayReprojection

var historyBlend

How much of the previous result to carry into a new one along the correspondence, 0 to 1. Steadies the image between results; too much of it smears a fast-moving stroke. Needs StreamDiffusionView.motion.

    @Volatile
    var historyBlend = config.historyBlend
        set(value) { require(value.isFinite() && value in 0.0..1.0); field = value }
    private var previousInput: StreamDiffusionFrame? = null

val displayReprojectionRequests: Long get() = temporal?.displayRequests ?: 0L

Display correspondences asked of StreamDiffusionView.motion so far; zero without one.

val historyBlendRequests: Long get() = temporal?.historyRequests ?: 0L

History correspondences asked of StreamDiffusionView.motion so far; zero without one, or at zero blend.

var noiseMotionFrames = 0L; private set

Captured frames that also carried a motion texture for the worker's noise.

var capturedFrames = 0L; private set

Source frames sent to the worker, which is at most StreamDiffusionConfig.fps a second.

var skippedCaptures = 0L; private set

Captures given up because every input slot was still with the worker.

var presentedFrames = 0L; private set

Results copied into StreamDiffusionView.output.

var droppedOutputs = 0L; private set

Results thrown away: from a generation before the last reset, or behind a backlog.

var lastSourceFrameId = -1L; private set

Which source frame the newest presented result came from, or -1 before the first one.

var lastSourceTimeNanos = 0L; private set

The StreamDiffusionView.sourceTimeNanos reading of that frame, so a caller can measure the round trip.

    private var frameId = 0L
    private var captureDue = 0L
    private var presentDue = 0L
    private val interval = 1_000_000_000L / config.fps
    private var initialized = false
    private var hasOutput = false
    @Volatile private var closed = false

init

    init {
        check(renderer.currentCommandBuffer == null && !renderer.isClosed)
        val support = StreamDiffusionSupport.probe(renderer, config)
        check(support.available) { support.reason }
        if (config.backend == "diffusion") require(source.width % 32 == 0 && source.height % 32 == 0) {
            "Diffusion input dimensions must be multiples of 32"
        }
        require(source.colorLayerCount == 1 && source.colorImageRefs[0]?.imageData?.sampleCount == VK_SAMPLE_COUNT_1_BIT)
        require(source.colorFormats[0] in setOf(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_SRGB,
            VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_B8G8R8A8_SRGB)) { "Diffusion input must be RGBA8/BGRA8" }
        require(source.parameters.colorTransferSrc)
        if (motion != null) require(source.parameters.colorSampled && source.defaultSampler != null) {
            "Diffusion reprojection needs a sampled source RTT with a default sampler"
        }
        check(!renderer.renderList.preTargets.containsKey(name))
        rawOutput = RenderToTexture(renderer) {
            width = source.width; height = source.height; format = source.colorFormats[0]
            useDepth = false; colorSampled = true; colorTransferDst = true
        }
        rawOutput.setDefaultSampler(anisotropyEnable = false)
        temporal = try { motion?.let { StreamDiffusionTemporalOutput(renderer, source, rawOutput, it) } }
        catch (e: Throwable) { cleanupRawOutput(); throw e }
        output = temporal?.output ?: rawOutput
        try { session = StreamDiffusionSession(renderer, source.width, source.height, source.colorFormats[0], config, motion != null) }
        catch (e: Throwable) { cleanupOutput(); throw e }
        renderer.renderList.preTargets[name] = this
    }

override fun render(command: CommandBuffer, index: Int)

One graphics frame of the exchange, called by the renderer rather than by box code: present a finished result if one is due, then capture the source again if the interval has passed and the worker has a free slot. Until the first result arrives, StreamDiffusionView.output carries a copy of the source, so there is a picture to draw from the moment the view is made.

body — 101 lines, from line 123
    override fun render(command: CommandBuffer, index: Int) {
        if (closed) return
        if (renderSource) source.render(command, index)
        val current = StreamDiffusionFrame(frameId++, sourceTimeNanos(), session.generation)
        motion?.onFrame(current)
        var arrived: StreamDiffusionFrame? = null
        // Show an actual source frame while the backend loads, using a GPU image copy.
        if (!hasOutput) {
            source.recordColorTransferSource(command) { from ->
                rawOutput.recordColorTransferDestination(command) { to ->
                    MemoryStack.stackPush().use { s ->
                        val r = VkImageCopy.calloc(1, s)
                            .srcSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
                            .dstSubresource { it.aspectMask(VK_IMAGE_ASPECT_COLOR_BIT).mipLevel(0).baseArrayLayer(0).layerCount(1) }
                            .extent { it.width(source.width).height(source.height).depth(1) }
                        vkCmdCopyImage(command.vkCommandBuffer, from, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                            to, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, r)
                    }
                }
            }
        }
        if (!session.isClosed && !initialized) {
            session.outputs.forEach { it.barrier(command, acquire = false, access = 0) }
            initialized = true
        }
        val now = System.nanoTime()
        while (true) {
            if (session.isClosed) break
            val result = session.results.peek() ?: break
            val stale = result.generation != session.generation || session.results.size > 4
            if (!stale && now < presentDue) break
            session.results.poll()
            val slot = session.outputs[result.slot]
            renderer.externalGraphicsSync(command, slot.semaphore, result.value, wait = true)
            slot.barrier(command, acquire = true, access = VK_ACCESS_2_TRANSFER_READ_BIT)
            if (!stale) {
                rawOutput.recordColorTransferDestination(command) { image ->
                    MemoryStack.stackPush().use { s ->
                        vkCmdCopyBufferToImage(command.vkCommandBuffer, slot.buffer, image,
                            VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, slot.copyRegion(s, source.width, source.height))
                    }
                }
                hasOutput = true; presentedFrames++
                lastSourceFrameId = result.frameId; lastSourceTimeNanos = result.timeNanos
                arrived = StreamDiffusionFrame(result.frameId, result.timeNanos, result.generation)
                presentDue = if (presentDue == 0L) now + interval else presentDue + ((now - presentDue) / interval + 1) * interval
            } else {
                droppedOutputs++
            }
            slot.barrier(command, acquire = false, access = VK_ACCESS_2_TRANSFER_READ_BIT)
            renderer.externalGraphicsSync(command, slot.semaphore, result.value + 1, wait = false)
            renderer.afterCurrentGraphicsFrameComplete { session.outputFree(result.slot, result.value + 1) }
            if (!stale) break
        }
        temporal?.let {
            it.displayReprojection = displayReprojection; it.blend = historyBlend
            it.resolve(command, index, current, arrived)
        }
        if (session.isClosed || !session.ready || now < captureDue) return
        captureDue = if (captureDue == 0L) now + interval else captureDue + ((now - captureDue) / interval + 1) * interval
        val id = current.id
        val inputIndex = session.reserveInput()
        if (inputIndex < 0) { skippedCaptures++; return }
        val slot = session.inputs[inputIndex]
        if (slot.used) {
            renderer.externalGraphicsSync(command, slot.semaphore, slot.value + 1, wait = true)
            slot.barrier(command, acquire = true, access = VK_ACCESS_2_TRANSFER_WRITE_BIT)
        }
        slot.value += if (slot.used) 2 else 1
        slot.used = true
        source.recordColorTransferSource(command) { image ->
            MemoryStack.stackPush().use { s ->
                vkCmdCopyImageToBuffer(command.vkCommandBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                    slot.buffer, slot.copyRegion(s, source.width, source.height))
            }
        }
        val previous = previousInput?.takeIf { it.generation == current.generation }
        val mapping = if (motion != null && previous != null)
            StreamDiffusionTemporalOutput.checkedMotion(motion.record(command,
                StreamDiffusionMotionRequest(current, previous, StreamDiffusionMotionUse.NOISE)), source) else null
        if (mapping != null) {
            mapping.recordColorTransferSource(command) { image ->
                MemoryStack.stackPush().use { stack ->
                    val region = slot.copyRegion(stack, source.width, source.height)
                        .bufferOffset(session.motionOffset)
                    vkCmdCopyImageToBuffer(command.vkCommandBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                        slot.buffer, region)
                }
            }
            noiseMotionFrames++
        }
        previousInput = current
        val motionReference = if (mapping != null) previous!!.id else -1L
        slot.barrier(command, acquire = false, access = VK_ACCESS_2_TRANSFER_WRITE_BIT)
        renderer.externalGraphicsSync(command, slot.semaphore, slot.value, wait = false)
        val value = slot.value
        val generation = current.generation
        val time = current.timeNanos
        renderer.afterCurrentGraphicsFrameComplete { session.frame(inputIndex, value, id, time, generation, motionReference) }
        capturedFrames++
    }

fun reset(prompt: String = config.prompt, strength: Double = config.strength, seed: Int = config.seed, noiseAdvection: Double = config.noiseAdvection)

Change artistic controls; preserves the last valid image during causal warm-up.

    fun reset(prompt: String = config.prompt, strength: Double = config.strength, seed: Int = config.seed, noiseAdvection: Double = config.noiseAdvection) =
        session.reset(prompt, strength, seed, noiseAdvection)

    private fun cleanupOutput() {
        temporal?.close()
        cleanupRawOutput()
    }
    private fun cleanupRawOutput() {
        renderer.cleanup += Runnable {
            rawOutput.imageViewArray.forEach { it.imageView.cleanup() }
            rawOutput.colorImageRefs.forEach { it?.cleanup() }
            rawOutput.defaultSampler?.cleanup()
        }
    }

override fun close()

Unregisters the view, stops the worker and releases its targets. Call it between graphics frames: there is a command buffer open during one, and closing then is refused.

    override fun close() {
        check(renderer.currentCommandBuffer == null) { "Close diffusion between graphics frames" }
        if (closed) return
        closed = true
        renderer.renderList.preTargets.remove(name, this)
        session.close()
        cleanupOutput()
    }
}