feat: control net
CI / android (push) Successful in 8m30s

This commit is contained in:
anran
2026-02-01 00:44:19 +08:00
parent 0c3ef2f6b4
commit c0e04852a7
8 changed files with 741 additions and 25 deletions
@@ -0,0 +1,59 @@
package moe.uni.comfyKmp
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? ->
if (uri != null) {
val image = readImageFromUri(context, uri)
onResult(image)
} else {
onResult(null)
}
}
return {
launcher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
}
private fun readImageFromUri(context: Context, uri: Uri): PickedImage? {
return try {
val mimeType = context.contentResolver.getType(uri) ?: "image/png"
val filename = getFileName(context, uri) ?: "image_${System.currentTimeMillis()}.png"
val bytes = context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: return null
PickedImage(filename, bytes, mimeType)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun getFileName(context: Context, uri: Uri): String? {
var name: String? = null
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
name = cursor.getString(nameIndex)
}
}
return name
}
@@ -0,0 +1,38 @@
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
/**
* Represents a picked image with its data
*/
data class PickedImage(
val filename: String,
val bytes: ByteArray,
val mimeType: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as PickedImage
return filename == other.filename &&
bytes.contentEquals(other.bytes) &&
mimeType == other.mimeType
}
override fun hashCode(): Int {
var result = filename.hashCode()
result = 31 * result + bytes.contentHashCode()
result = 31 * result + mimeType.hashCode()
return result
}
}
/**
* Remembers an image picker launcher for selecting images from gallery
* @param onResult Callback with the picked image, or null if cancelled
* @return A function to launch the image picker
*/
@Composable
expect fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit
@@ -6,21 +6,28 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
fun extractImagesFromHistory(history: JsonElement): List<ImageRef> { /**
val result = mutableListOf<ImageRef>() * Extract images from history with their associated node IDs
*/
fun extractImagesFromHistoryWithNodeId(history: JsonElement): List<NodeImageRef> {
val result = mutableListOf<NodeImageRef>()
val root = history as? JsonObject ?: return emptyList() val root = history as? JsonObject ?: return emptyList()
root.values.forEach { promptEntry -> root.values.forEach { promptEntry ->
val outputs = promptEntry.jsonObject["outputs"]?.jsonObject ?: return@forEach val outputs = promptEntry.jsonObject["outputs"]?.jsonObject ?: return@forEach
outputs.values.forEach { nodeOutput -> outputs.forEach { (nodeId, nodeOutput) ->
val images = nodeOutput.jsonObject["images"] as? JsonArray ?: return@forEach val images = nodeOutput.jsonObject["images"] as? JsonArray ?: return@forEach
images.forEach { img -> images.forEach { img ->
val obj = img.jsonObject val obj = img.jsonObject
val filename = obj["filename"]?.jsonPrimitive?.content ?: return@forEach val filename = obj["filename"]?.jsonPrimitive?.content ?: return@forEach
val subfolder = obj["subfolder"]?.jsonPrimitive?.content val subfolder = obj["subfolder"]?.jsonPrimitive?.content
val type = obj["type"]?.jsonPrimitive?.content val type = obj["type"]?.jsonPrimitive?.content
result.add(ImageRef(filename, subfolder, type)) result.add(NodeImageRef(nodeId, ImageRef(filename, subfolder, type)))
} }
} }
} }
return result return result
} }
fun extractImagesFromHistory(history: JsonElement): List<ImageRef> {
return extractImagesFromHistoryWithNodeId(history).map { it.imageRef }
}
@@ -50,6 +50,21 @@ data class ImageRef(
val type: String? = null val type: String? = null
) )
/**
* Image reference with associated node ID for tracking which node produced the image
*/
data class NodeImageRef(
val nodeId: String,
val imageRef: ImageRef
)
@Serializable
data class ImageUploadResponse(
val name: String,
val subfolder: String = "",
val type: String = "input"
)
@Serializable @Serializable
data class WsMessage( data class WsMessage(
val type: String, val type: String,
@@ -3,13 +3,18 @@ package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import moe.uni.comfyKmp.data.ImageUploadResponse
import moe.uni.comfyKmp.data.PromptRequest import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.PromptResponse import moe.uni.comfyKmp.data.PromptResponse
@@ -69,4 +74,23 @@ class ComfyApiClient(
suspend fun getBytes(url: String): ByteArray { suspend fun getBytes(url: String): ByteArray {
return httpClient.get(url).body() return httpClient.get(url).body()
} }
suspend fun uploadImage(
baseUrl: String,
filename: String,
bytes: ByteArray,
mimeType: String = "image/png",
overwrite: Boolean = true
): ImageUploadResponse {
return httpClient.submitFormWithBinaryData(
url = "${baseUrl.trimEnd('/')}/upload/image",
formData = formData {
append("image", bytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$filename\"")
})
append("overwrite", overwrite.toString())
}
).body()
}
} }
@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -14,16 +15,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Casino import androidx.compose.material.icons.filled.Casino
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
@@ -48,11 +54,15 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import moe.uni.comfyKmp.data.NodeStatus import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.ui.theme.ComfySpacing import moe.uni.comfyKmp.ui.theme.ComfySpacing
@@ -231,8 +241,13 @@ enum class EditableNodeType {
CHECKPOINT_LOADER, CHECKPOINT_LOADER,
VAE_LOADER, VAE_LOADER,
LORA_LOADER, LORA_LOADER,
CONTROLNET_LOADER,
CLIP_TEXT_ENCODE, CLIP_TEXT_ENCODE,
EMPTY_LATENT_IMAGE, EMPTY_LATENT_IMAGE,
LOAD_IMAGE,
CANNY_PREPROCESSOR,
CONTROLNET_APPLY,
PREVIEW_IMAGE,
OTHER OTHER
} }
@@ -249,12 +264,25 @@ fun detectNodeType(classType: String): EditableNodeType {
normalized.contains("loraloader") || normalized.contains("loraloader") ||
normalized.contains("lora_loader") || normalized.contains("lora_loader") ||
normalized == "load lora" -> EditableNodeType.LORA_LOADER normalized == "load lora" -> EditableNodeType.LORA_LOADER
normalized.contains("controlnetloader") ||
normalized.contains("controlnet_loader") ||
normalized == "load controlnet" -> EditableNodeType.CONTROLNET_LOADER
normalized.contains("cliptextencode") || normalized.contains("cliptextencode") ||
normalized.contains("clip_text_encode") || normalized.contains("clip_text_encode") ||
normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE
normalized.contains("emptylatentimage") || normalized.contains("emptylatentimage") ||
normalized.contains("empty_latent_image") || normalized.contains("empty_latent_image") ||
normalized == "empty latent image" -> EditableNodeType.EMPTY_LATENT_IMAGE normalized == "empty latent image" -> EditableNodeType.EMPTY_LATENT_IMAGE
normalized.contains("loadimage") ||
normalized == "load image" -> EditableNodeType.LOAD_IMAGE
normalized.contains("cannyedge") ||
(normalized.contains("canny") && normalized.contains("preprocessor")) ->
EditableNodeType.CANNY_PREPROCESSOR
normalized.contains("apply controlnet") ||
normalized.contains("applycontrolnet") ||
normalized.contains("cr apply controlnet") -> EditableNodeType.CONTROLNET_APPLY
normalized.contains("previewimage") ||
normalized == "preview image" -> EditableNodeType.PREVIEW_IMAGE
else -> EditableNodeType.OTHER else -> EditableNodeType.OTHER
} }
} }
@@ -264,6 +292,7 @@ fun getModelFolder(nodeType: EditableNodeType): String? {
EditableNodeType.CHECKPOINT_LOADER -> "checkpoints" EditableNodeType.CHECKPOINT_LOADER -> "checkpoints"
EditableNodeType.VAE_LOADER -> "vae" EditableNodeType.VAE_LOADER -> "vae"
EditableNodeType.LORA_LOADER -> "loras" EditableNodeType.LORA_LOADER -> "loras"
EditableNodeType.CONTROLNET_LOADER -> "controlnet"
else -> null else -> null
} }
} }
@@ -278,11 +307,16 @@ fun EditableNodeCard(
isRandomSeedEnabled: Boolean = false, isRandomSeedEnabled: Boolean = false,
modelOptions: List<String> = emptyList(), modelOptions: List<String> = emptyList(),
isLoadingModels: Boolean = false, isLoadingModels: Boolean = false,
imagePreview: ImageBitmap? = null,
isUploadingImage: Boolean = false,
nodeOutputPreview: ImageBitmap? = null,
isLoadingNodeOutput: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onInputChange: (field: String, value: JsonElement) -> Unit = { _, _ -> }, onInputChange: (field: String, value: JsonElement) -> Unit = { _, _ -> },
onRandomSeedToggle: (enabled: Boolean) -> Unit = {}, onRandomSeedToggle: (enabled: Boolean) -> Unit = {},
onRandomizeSeed: () -> Unit = {}, onRandomizeSeed: () -> Unit = {},
onLoadModels: () -> Unit = {} onLoadModels: () -> Unit = {},
onSelectImage: () -> Unit = {}
) { ) {
val comfyColors = MaterialTheme.comfyColors val comfyColors = MaterialTheme.comfyColors
val nodeType = remember(classType) { detectNodeType(classType) } val nodeType = remember(classType) { detectNodeType(classType) }
@@ -352,8 +386,13 @@ fun EditableNodeCard(
EditableNodeType.CHECKPOINT_LOADER -> "模型" EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE" EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA" EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
EditableNodeType.CLIP_TEXT_ENCODE -> "提示词" EditableNodeType.CLIP_TEXT_ENCODE -> "提示词"
EditableNodeType.EMPTY_LATENT_IMAGE -> "空潜图" EditableNodeType.EMPTY_LATENT_IMAGE -> "空潜图"
EditableNodeType.LOAD_IMAGE -> "图像"
EditableNodeType.CANNY_PREPROCESSOR -> "Canny边缘"
EditableNodeType.CONTROLNET_APPLY -> "应用CN"
EditableNodeType.PREVIEW_IMAGE -> "预览"
else -> "" else -> ""
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -411,7 +450,8 @@ fun EditableNodeCard(
} }
EditableNodeType.CHECKPOINT_LOADER, EditableNodeType.CHECKPOINT_LOADER,
EditableNodeType.VAE_LOADER, EditableNodeType.VAE_LOADER,
EditableNodeType.LORA_LOADER -> { EditableNodeType.LORA_LOADER,
EditableNodeType.CONTROLNET_LOADER -> {
ModelSelector( ModelSelector(
nodeType = nodeType, nodeType = nodeType,
inputs = inputs, inputs = inputs,
@@ -433,6 +473,33 @@ fun EditableNodeCard(
onInputChange = onInputChange onInputChange = onInputChange
) )
} }
EditableNodeType.LOAD_IMAGE -> {
LoadImageEditor(
inputs = inputs,
imagePreview = imagePreview,
isUploading = isUploadingImage,
onSelectImage = onSelectImage,
onInputChange = onInputChange
)
}
EditableNodeType.CANNY_PREPROCESSOR -> {
CannyEdgeEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.CONTROLNET_APPLY -> {
ControlNetApplyEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.PREVIEW_IMAGE -> {
PreviewImageViewer(
outputPreview = nodeOutputPreview,
isLoading = isLoadingNodeOutput
)
}
EditableNodeType.OTHER -> { EditableNodeType.OTHER -> {
// Show read-only inputs for other node types // Show read-only inputs for other node types
val previewInputs = inputs.entries val previewInputs = inputs.entries
@@ -606,6 +673,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "ckpt_name" EditableNodeType.CHECKPOINT_LOADER -> "ckpt_name"
EditableNodeType.VAE_LOADER -> "vae_name" EditableNodeType.VAE_LOADER -> "vae_name"
EditableNodeType.LORA_LOADER -> "lora_name" EditableNodeType.LORA_LOADER -> "lora_name"
EditableNodeType.CONTROLNET_LOADER -> "control_net_name"
else -> return else -> return
} }
@@ -622,6 +690,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "模型" EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE" EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA" EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
else -> "选择" else -> "选择"
}, },
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -876,3 +945,285 @@ private fun PromptEditor(
) )
} }
} }
@Composable
private fun LoadImageEditor(
inputs: Map<String, JsonElement>,
imagePreview: ImageBitmap?,
isUploading: Boolean,
onSelectImage: () -> Unit,
onInputChange: (String, JsonElement) -> Unit
) {
val currentImage = inputs["image"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
Text(
text = "图像",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 缩略图预览
if (imagePreview != null) {
Image(
bitmap = imagePreview,
contentDescription = "预览",
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = currentImage.ifEmpty { "未选择图片" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
FilledTonalButton(
onClick = onSelectImage,
enabled = !isUploading,
contentPadding = PaddingValues(horizontal = ComfySpacing.md)
) {
if (isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
Text("上传中...")
} else {
Icon(
imageVector = Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("选择图片")
}
}
}
}
}
@Composable
private fun CannyEdgeEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val lowThreshold = inputs["low_threshold"]?.let {
(it as? JsonPrimitive)?.intOrNull
} ?: 100
val highThreshold = inputs["high_threshold"]?.let {
(it as? JsonPrimitive)?.intOrNull
} ?: 200
val resolution = inputs["resolution"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: "512"
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
// Low Threshold Slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "低阈值",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = lowThreshold.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = lowThreshold.toFloat(),
onValueChange = {
onInputChange("low_threshold", JsonPrimitive(it.toInt()))
},
valueRange = 0f..255f,
modifier = Modifier.fillMaxWidth()
)
}
// High Threshold Slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "高阈值",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = highThreshold.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = highThreshold.toFloat(),
onValueChange = {
onInputChange("high_threshold", JsonPrimitive(it.toInt()))
},
valueRange = 0f..255f,
modifier = Modifier.fillMaxWidth()
)
}
// Resolution TextField
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)) {
Text(
text = "分辨率",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = resolution,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let {
onInputChange("resolution", JsonPrimitive(it))
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("如 512") }
)
}
}
}
@Composable
private fun ControlNetApplyEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val switchValue = inputs["switch"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: "On"
val strength = inputs["strength"]?.let {
(it as? JsonPrimitive)?.floatOrNull
} ?: 1.0f
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
// Switch toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "启用",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Switch(
checked = switchValue == "On",
onCheckedChange = { enabled ->
onInputChange("switch", JsonPrimitive(if (enabled) "On" else "Off"))
}
)
}
// Strength slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "强度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatTwoDecimals(strength),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = strength,
onValueChange = {
onInputChange("strength", JsonPrimitive(it.toDouble()))
},
valueRange = 0f..2f,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun PreviewImageViewer(
outputPreview: ImageBitmap?,
isLoading: Boolean
) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Text(
text = "预览输出",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
when {
outputPreview != null -> {
Image(
bitmap = outputPreview,
contentDescription = "预览输出",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
isLoading -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
Text(
text = "加载中...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
Text(
text = "等待执行...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@@ -45,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -75,15 +76,18 @@ import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfyKmp.data.ComfyJson import moe.uni.comfyKmp.data.ComfyJson
import moe.uni.comfyKmp.data.ImageRef import moe.uni.comfyKmp.data.ImageRef
import moe.uni.comfyKmp.data.NodeExecutionState import moe.uni.comfyKmp.data.NodeExecutionState
import moe.uni.comfyKmp.data.NodeImageRef
import moe.uni.comfyKmp.data.NodeStatus import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.data.PromptRequest import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.extractImagesFromHistory import moe.uni.comfyKmp.data.extractImagesFromHistoryWithNodeId
import moe.uni.comfyKmp.data.extractPromptObject import moe.uni.comfyKmp.data.extractPromptObject
import moe.uni.comfyKmp.data.parsePromptNodes import moe.uni.comfyKmp.data.parsePromptNodes
import moe.uni.comfyKmp.data.saveCoverImage import moe.uni.comfyKmp.data.saveCoverImage
import moe.uni.comfyKmp.data.saveImageToGallery import moe.uni.comfyKmp.data.saveImageToGallery
import moe.uni.comfyKmp.data.updateNodeInput import moe.uni.comfyKmp.data.updateNodeInput
import moe.uni.comfyKmp.di.LocalAppContainer import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.PickedImage
import moe.uni.comfyKmp.rememberImagePickerLauncher
import moe.uni.comfyKmp.network.ComfyApiClient import moe.uni.comfyKmp.network.ComfyApiClient
import moe.uni.comfyKmp.network.ComfyWebSocketClient import moe.uni.comfyKmp.network.ComfyWebSocketClient
import moe.uni.comfyKmp.network.WsConnectionStatus import moe.uni.comfyKmp.network.WsConnectionStatus
@@ -173,6 +177,17 @@ private fun CompactWorkflowRunLayout(
) )
val sheetPeekHeight = 120.dp val sheetPeekHeight = 120.dp
// Image picker state
var pendingImageNodeId by remember { mutableStateOf<String?>(null) }
val launchImagePicker = rememberImagePickerLauncher { pickedImage ->
pendingImageNodeId?.let { nodeId ->
if (pickedImage != null) {
model.onImageSelected(nodeId, pickedImage)
}
}
pendingImageNodeId = null
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current val density = LocalDensity.current
val layoutHeightPx = with(density) { maxHeight.toPx() } val layoutHeightPx = with(density) { maxHeight.toPx() }
@@ -212,10 +227,18 @@ private fun CompactWorkflowRunLayout(
randomSeedNodes = model.randomSeedNodes, randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices, modelChoices = model.modelChoices,
loadingModels = model.loadingModels, loadingModels = model.loadingModels,
uploadingImageNodes = model.uploadingImageNodes,
uploadedImagePreviews = model.uploadedImagePreviews,
nodeOutputPreviews = model.nodeOutputPreviews,
isLoadingNodeOutputs = model.running,
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) }, onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) }, onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) }, onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) }, onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
onSelectImage = { nodeId ->
pendingImageNodeId = nodeId
launchImagePicker()
},
modifier = Modifier.navigationBarsPadding() modifier = Modifier.navigationBarsPadding()
) )
}, },
@@ -381,6 +404,17 @@ private fun ExpandedNodeListPanel(
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors, comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Image picker state
var pendingImageNodeId by remember { mutableStateOf<String?>(null) }
val launchImagePicker = rememberImagePickerLauncher { pickedImage ->
pendingImageNodeId?.let { nodeId ->
if (pickedImage != null) {
model.onImageSelected(nodeId, pickedImage)
}
}
pendingImageNodeId = null
}
Column( Column(
modifier = modifier modifier = modifier
.background(comfyColors.cardBackground) .background(comfyColors.cardBackground)
@@ -418,6 +452,10 @@ private fun ExpandedNodeListPanel(
isRandomSeedEnabled = model.randomSeedNodes.contains(node.nodeId), isRandomSeedEnabled = model.randomSeedNodes.contains(node.nodeId),
modelOptions = model.modelChoices[node.nodeId] ?: emptyList(), modelOptions = model.modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = model.loadingModels.contains(node.nodeId), isLoadingModels = model.loadingModels.contains(node.nodeId),
imagePreview = model.uploadedImagePreviews[node.nodeId],
isUploadingImage = model.uploadingImageNodes.contains(node.nodeId),
nodeOutputPreview = model.nodeOutputPreviews[node.nodeId],
isLoadingNodeOutput = model.running && model.nodeOutputPreviews[node.nodeId] == null,
onInputChange = { field, value -> onInputChange = { field, value ->
model.updateNodeInput(node.nodeId, field, value) model.updateNodeInput(node.nodeId, field, value)
}, },
@@ -425,7 +463,11 @@ private fun ExpandedNodeListPanel(
model.setRandomSeedMode(node.nodeId, enabled) model.setRandomSeedMode(node.nodeId, enabled)
}, },
onRandomizeSeed = { model.randomizeSeed(node.nodeId) }, onRandomizeSeed = { model.randomizeSeed(node.nodeId) },
onLoadModels = { model.loadModelsForNode(node.nodeId) } onLoadModels = { model.loadModelsForNode(node.nodeId) },
onSelectImage = {
pendingImageNodeId = node.nodeId
launchImagePicker()
}
) )
} }
} }
@@ -522,10 +564,15 @@ private fun NodeListSheet(
randomSeedNodes: Set<String>, randomSeedNodes: Set<String>,
modelChoices: Map<String, List<String>>, modelChoices: Map<String, List<String>>,
loadingModels: Set<String>, loadingModels: Set<String>,
uploadingImageNodes: Set<String>,
uploadedImagePreviews: Map<String, ImageBitmap>,
nodeOutputPreviews: Map<String, ImageBitmap>,
isLoadingNodeOutputs: Boolean,
onInputChange: (nodeId: String, field: String, value: JsonElement) -> Unit, onInputChange: (nodeId: String, field: String, value: JsonElement) -> Unit,
onRandomSeedToggle: (nodeId: String, enabled: Boolean) -> Unit, onRandomSeedToggle: (nodeId: String, enabled: Boolean) -> Unit,
onRandomizeSeed: (nodeId: String) -> Unit, onRandomizeSeed: (nodeId: String) -> Unit,
onLoadModels: (nodeId: String) -> Unit, onLoadModels: (nodeId: String) -> Unit,
onSelectImage: (nodeId: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumn( LazyColumn(
@@ -546,10 +593,15 @@ private fun NodeListSheet(
isRandomSeedEnabled = randomSeedNodes.contains(node.nodeId), isRandomSeedEnabled = randomSeedNodes.contains(node.nodeId),
modelOptions = modelChoices[node.nodeId] ?: emptyList(), modelOptions = modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = loadingModels.contains(node.nodeId), isLoadingModels = loadingModels.contains(node.nodeId),
imagePreview = uploadedImagePreviews[node.nodeId],
isUploadingImage = uploadingImageNodes.contains(node.nodeId),
nodeOutputPreview = nodeOutputPreviews[node.nodeId],
isLoadingNodeOutput = isLoadingNodeOutputs && nodeOutputPreviews[node.nodeId] == null,
onInputChange = { field, value -> onInputChange(node.nodeId, field, value) }, onInputChange = { field, value -> onInputChange(node.nodeId, field, value) },
onRandomSeedToggle = { enabled -> onRandomSeedToggle(node.nodeId, enabled) }, onRandomSeedToggle = { enabled -> onRandomSeedToggle(node.nodeId, enabled) },
onRandomizeSeed = { onRandomizeSeed(node.nodeId) }, onRandomizeSeed = { onRandomizeSeed(node.nodeId) },
onLoadModels = { onLoadModels(node.nodeId) } onLoadModels = { onLoadModels(node.nodeId) },
onSelectImage = { onSelectImage(node.nodeId) }
) )
} }
} }
@@ -597,8 +649,18 @@ private class WorkflowRunScreenModel(
var loadingModels by mutableStateOf<Set<String>>(emptySet()) var loadingModels by mutableStateOf<Set<String>>(emptySet())
private set private set
// Image upload state
var uploadingImageNodes by mutableStateOf<Set<String>>(emptySet())
private set
var uploadedImagePreviews by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private set
// Node output preview images (for PreviewImage nodes)
var nodeOutputPreviews by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private set
private var promptId by mutableStateOf<String?>(null) private var promptId by mutableStateOf<String?>(null)
private var imageRefs by mutableStateOf<List<ImageRef>>(emptyList()) private var nodeImageRefs by mutableStateOf<List<NodeImageRef>>(emptyList())
private var imageBitmaps by mutableStateOf<Map<String, ImageBitmap>>(emptyMap()) private var imageBitmaps by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private var imageBytes by mutableStateOf<Map<String, ByteArray>>(emptyMap()) private var imageBytes by mutableStateOf<Map<String, ByteArray>>(emptyMap())
private var isLoadingImages by mutableStateOf(false) private var isLoadingImages by mutableStateOf(false)
@@ -607,12 +669,23 @@ private class WorkflowRunScreenModel(
private var wsStateJob: Job? = null private var wsStateJob: Job? = null
val galleryImages: List<GalleryImage> val galleryImages: List<GalleryImage>
get() = imageRefs.map { ref -> get() {
GalleryImage( // Filter out PreviewImage nodes, only show SaveImage outputs in gallery
filename = ref.filename, val previewNodeIds = nodeStates
bitmap = imageBitmaps[ref.filename], .filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename) .map { it.nodeId }
) .toSet()
return nodeImageRefs
.filter { it.nodeId !in previewNodeIds }
.map { nodeRef ->
val ref = nodeRef.imageRef
GalleryImage(
filename = ref.filename,
bitmap = imageBitmaps[ref.filename],
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
)
}
} }
val orderedNodeStates: List<NodeExecutionState> val orderedNodeStates: List<NodeExecutionState>
@@ -634,7 +707,8 @@ private class WorkflowRunScreenModel(
} }
fun getPreviewImage(index: Int): PreviewImage? { fun getPreviewImage(index: Int): PreviewImage? {
val ref = imageRefs.getOrNull(index) ?: return null val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return null
val bytes = imageBytes[ref.filename] ?: return null val bytes = imageBytes[ref.filename] ?: return null
return PreviewImage(ref.filename, bytes) return PreviewImage(ref.filename, bytes)
} }
@@ -707,6 +781,30 @@ private class WorkflowRunScreenModel(
}.map { it.nodeId } }.map { it.nodeId }
modelNodeIds.forEach { loadModelsForNode(it) } modelNodeIds.forEach { loadModelsForNode(it) }
} }
fun onImageSelected(nodeId: String, pickedImage: PickedImage) {
screenModelScope.launch {
uploadingImageNodes = uploadingImageNodes + nodeId
try {
val response = apiClient.uploadImage(
baseUrl = baseUrl,
filename = pickedImage.filename,
bytes = pickedImage.bytes,
mimeType = pickedImage.mimeType
)
updateNodeInput(nodeId, "image", JsonPrimitive(response.name))
val bitmap = pickedImage.bytes.decodeToImageBitmap()
uploadedImagePreviews = uploadedImagePreviews + (nodeId to bitmap)
} catch (e: Exception) {
onToast("图片上传失败: ${e.message}")
} finally {
uploadingImageNodes = uploadingImageNodes - nodeId
}
}
}
private fun scheduleSave() { private fun scheduleSave() {
saveJob?.cancel() saveJob?.cancel()
@@ -947,9 +1045,9 @@ private class WorkflowRunScreenModel(
screenModelScope.launch { screenModelScope.launch {
try { try {
val history = apiClient.getHistory(baseUrl, id) val history = apiClient.getHistory(baseUrl, id)
val newRefs = extractImagesFromHistory(history) val newRefs = extractImagesFromHistoryWithNodeId(history)
if (newRefs.isNotEmpty() && newRefs != imageRefs) { if (newRefs.isNotEmpty() && newRefs != nodeImageRefs) {
imageRefs = newRefs nodeImageRefs = newRefs
loadImages() loadImages()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -960,7 +1058,15 @@ private class WorkflowRunScreenModel(
private fun loadImages() { private fun loadImages() {
isLoadingImages = true isLoadingImages = true
imageRefs.forEach { ref ->
// Get PreviewImage node IDs
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.map { it.nodeId }
.toSet()
nodeImageRefs.forEach { nodeRef ->
val ref = nodeRef.imageRef
if (!imageBitmaps.containsKey(ref.filename)) { if (!imageBitmaps.containsKey(ref.filename)) {
screenModelScope.launch { screenModelScope.launch {
try { try {
@@ -969,11 +1075,16 @@ private class WorkflowRunScreenModel(
imageBytes = imageBytes + (ref.filename to bytes) imageBytes = imageBytes + (ref.filename to bytes)
val bitmap = bytes.decodeToImageBitmap() val bitmap = bytes.decodeToImageBitmap()
imageBitmaps = imageBitmaps + (ref.filename to bitmap) imageBitmaps = imageBitmaps + (ref.filename to bitmap)
// If this is a PreviewImage node, update nodeOutputPreviews
if (nodeRef.nodeId in previewNodeIds) {
nodeOutputPreviews = nodeOutputPreviews + (nodeRef.nodeId to bitmap)
}
} catch (e: Exception) { } catch (e: Exception) {
// Image loading failed // Image loading failed
} finally { } finally {
// Check if all images are loaded // Check if all images are loaded
if (imageBitmaps.size >= imageRefs.size) { if (imageBitmaps.size >= nodeImageRefs.size) {
isLoadingImages = false isLoadingImages = false
} }
} }
@@ -981,13 +1092,14 @@ private class WorkflowRunScreenModel(
} }
} }
// If all images are already cached // If all images are already cached
if (imageRefs.all { imageBitmaps.containsKey(it.filename) }) { if (nodeImageRefs.all { imageBitmaps.containsKey(it.imageRef.filename) }) {
isLoadingImages = false isLoadingImages = false
} }
} }
fun saveImage(index: Int) { fun saveImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return val bytes = imageBytes[ref.filename] ?: return
screenModelScope.launch { screenModelScope.launch {
try { try {
@@ -1000,7 +1112,8 @@ private class WorkflowRunScreenModel(
} }
fun setCoverImage(index: Int) { fun setCoverImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return val bytes = imageBytes[ref.filename] ?: return
val current = workflow ?: return val current = workflow ?: return
@@ -1014,6 +1127,16 @@ private class WorkflowRunScreenModel(
workflow = updated workflow = updated
onToast("已设置为封面") onToast("已设置为封面")
} }
private fun getGalleryImageRefs(): List<ImageRef> {
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.map { it.nodeId }
.toSet()
return nodeImageRefs
.filter { it.nodeId !in previewNodeIds }
.map { it.imageRef }
}
private fun updateNodeStatus(nodeId: String, status: NodeStatus) { private fun updateNodeStatus(nodeId: String, status: NodeStatus) {
nodeStates = nodeStates.map { state -> nodeStates = nodeStates.map { state ->
@@ -0,0 +1,99 @@
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.Foundation.NSData
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
import platform.PhotosUI.PHPickerConfiguration
import platform.PhotosUI.PHPickerFilter
import platform.PhotosUI.PHPickerResult
import platform.PhotosUI.PHPickerViewController
import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
import platform.UIKit.UIApplication
import platform.UIKit.UIViewController
import platform.UIKit.UIWindow
import platform.UniformTypeIdentifiers.UTTypeImage
import platform.darwin.NSObject
import platform.posix.memcpy
@Composable
actual fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit {
val onResultState = rememberUpdatedState(onResult)
val controller = remember {
IosImagePickerController { image ->
onResultState.value(image)
}
}
return { controller.launch() }
}
private class IosImagePickerController(
private val onResult: (PickedImage?) -> Unit
) : NSObject(), PHPickerViewControllerDelegateProtocol {
fun launch() {
val presenter = currentPresenter()
if (presenter == null) {
onResult(null)
return
}
val config = PHPickerConfiguration().apply {
filter = PHPickerFilter.imagesFilter
selectionLimit = 1
}
val picker = PHPickerViewController(configuration = config)
picker.delegate = this
presenter.presentViewController(picker, animated = true, completion = null)
}
override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
picker.dismissViewControllerAnimated(true, completion = null)
val result = didFinishPicking.firstOrNull() as? PHPickerResult
if (result == null) {
onResult(null)
return
}
result.itemProvider.loadDataRepresentationForTypeIdentifier(
typeIdentifier = UTTypeImage.identifier
) { data, error ->
if (data != null && error == null) {
val bytes = data.toByteArray()
val timestamp = NSDate().timeIntervalSince1970.toLong()
val filename = "image_$timestamp.png"
onResult(PickedImage(filename, bytes, "image/png"))
} else {
onResult(null)
}
}
}
}
private fun currentPresenter(): UIViewController? {
val app = UIApplication.sharedApplication
val window = (app.keyWindow ?: app.windows.firstOrNull()) as? UIWindow
var controller = window?.rootViewController
while (controller?.presentedViewController != null) {
controller = controller.presentedViewController
}
return controller
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val size = length.toInt()
val bytes = ByteArray(size)
if (size > 0) {
memcpy(bytes.refTo(0), this.bytes, length)
}
return bytes
}