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.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()
root.values.forEach { promptEntry ->
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
images.forEach { img ->
val obj = img.jsonObject
val filename = obj["filename"]?.jsonPrimitive?.content ?: return@forEach
val subfolder = obj["subfolder"]?.jsonPrimitive?.content
val type = obj["type"]?.jsonPrimitive?.content
result.add(ImageRef(filename, subfolder, type))
result.add(NodeImageRef(nodeId, ImageRef(filename, subfolder, type)))
}
}
}
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
)
/**
* 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
data class WsMessage(
val type: String,
@@ -3,13 +3,18 @@ package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
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.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import moe.uni.comfyKmp.data.ImageUploadResponse
import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.PromptResponse
@@ -69,4 +74,23 @@ class ComfyApiClient(
suspend fun getBytes(url: String): ByteArray {
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.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.PaddingValues
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Casino
import androidx.compose.material.icons.filled.ExpandLess
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.ExperimentalMaterial3Api
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.shadow
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.unit.dp
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.ui.theme.ComfySpacing
@@ -231,8 +241,13 @@ enum class EditableNodeType {
CHECKPOINT_LOADER,
VAE_LOADER,
LORA_LOADER,
CONTROLNET_LOADER,
CLIP_TEXT_ENCODE,
EMPTY_LATENT_IMAGE,
LOAD_IMAGE,
CANNY_PREPROCESSOR,
CONTROLNET_APPLY,
PREVIEW_IMAGE,
OTHER
}
@@ -249,12 +264,25 @@ fun detectNodeType(classType: String): EditableNodeType {
normalized.contains("loraloader") ||
normalized.contains("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("clip_text_encode") ||
normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE
normalized.contains("emptylatentimage") ||
normalized.contains("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
}
}
@@ -264,6 +292,7 @@ fun getModelFolder(nodeType: EditableNodeType): String? {
EditableNodeType.CHECKPOINT_LOADER -> "checkpoints"
EditableNodeType.VAE_LOADER -> "vae"
EditableNodeType.LORA_LOADER -> "loras"
EditableNodeType.CONTROLNET_LOADER -> "controlnet"
else -> null
}
}
@@ -278,11 +307,16 @@ fun EditableNodeCard(
isRandomSeedEnabled: Boolean = false,
modelOptions: List<String> = emptyList(),
isLoadingModels: Boolean = false,
imagePreview: ImageBitmap? = null,
isUploadingImage: Boolean = false,
nodeOutputPreview: ImageBitmap? = null,
isLoadingNodeOutput: Boolean = false,
modifier: Modifier = Modifier,
onInputChange: (field: String, value: JsonElement) -> Unit = { _, _ -> },
onRandomSeedToggle: (enabled: Boolean) -> Unit = {},
onRandomizeSeed: () -> Unit = {},
onLoadModels: () -> Unit = {}
onLoadModels: () -> Unit = {},
onSelectImage: () -> Unit = {}
) {
val comfyColors = MaterialTheme.comfyColors
val nodeType = remember(classType) { detectNodeType(classType) }
@@ -352,8 +386,13 @@ fun EditableNodeCard(
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
EditableNodeType.CLIP_TEXT_ENCODE -> "提示词"
EditableNodeType.EMPTY_LATENT_IMAGE -> "空潜图"
EditableNodeType.LOAD_IMAGE -> "图像"
EditableNodeType.CANNY_PREPROCESSOR -> "Canny边缘"
EditableNodeType.CONTROLNET_APPLY -> "应用CN"
EditableNodeType.PREVIEW_IMAGE -> "预览"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
@@ -411,7 +450,8 @@ fun EditableNodeCard(
}
EditableNodeType.CHECKPOINT_LOADER,
EditableNodeType.VAE_LOADER,
EditableNodeType.LORA_LOADER -> {
EditableNodeType.LORA_LOADER,
EditableNodeType.CONTROLNET_LOADER -> {
ModelSelector(
nodeType = nodeType,
inputs = inputs,
@@ -433,6 +473,33 @@ fun EditableNodeCard(
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 -> {
// Show read-only inputs for other node types
val previewInputs = inputs.entries
@@ -606,6 +673,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "ckpt_name"
EditableNodeType.VAE_LOADER -> "vae_name"
EditableNodeType.LORA_LOADER -> "lora_name"
EditableNodeType.CONTROLNET_LOADER -> "control_net_name"
else -> return
}
@@ -622,6 +690,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
else -> "选择"
},
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.ImageRef
import moe.uni.comfyKmp.data.NodeExecutionState
import moe.uni.comfyKmp.data.NodeImageRef
import moe.uni.comfyKmp.data.NodeStatus
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.parsePromptNodes
import moe.uni.comfyKmp.data.saveCoverImage
import moe.uni.comfyKmp.data.saveImageToGallery
import moe.uni.comfyKmp.data.updateNodeInput
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.ComfyWebSocketClient
import moe.uni.comfyKmp.network.WsConnectionStatus
@@ -173,6 +177,17 @@ private fun CompactWorkflowRunLayout(
)
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()) {
val density = LocalDensity.current
val layoutHeightPx = with(density) { maxHeight.toPx() }
@@ -212,10 +227,18 @@ private fun CompactWorkflowRunLayout(
randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices,
loadingModels = model.loadingModels,
uploadingImageNodes = model.uploadingImageNodes,
uploadedImagePreviews = model.uploadedImagePreviews,
nodeOutputPreviews = model.nodeOutputPreviews,
isLoadingNodeOutputs = model.running,
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
onSelectImage = { nodeId ->
pendingImageNodeId = nodeId
launchImagePicker()
},
modifier = Modifier.navigationBarsPadding()
)
},
@@ -381,6 +404,17 @@ private fun ExpandedNodeListPanel(
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
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(
modifier = modifier
.background(comfyColors.cardBackground)
@@ -418,6 +452,10 @@ private fun ExpandedNodeListPanel(
isRandomSeedEnabled = model.randomSeedNodes.contains(node.nodeId),
modelOptions = model.modelChoices[node.nodeId] ?: emptyList(),
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 ->
model.updateNodeInput(node.nodeId, field, value)
},
@@ -425,7 +463,11 @@ private fun ExpandedNodeListPanel(
model.setRandomSeedMode(node.nodeId, enabled)
},
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>,
modelChoices: Map<String, List<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,
onRandomSeedToggle: (nodeId: String, enabled: Boolean) -> Unit,
onRandomizeSeed: (nodeId: String) -> Unit,
onLoadModels: (nodeId: String) -> Unit,
onSelectImage: (nodeId: String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -546,10 +593,15 @@ private fun NodeListSheet(
isRandomSeedEnabled = randomSeedNodes.contains(node.nodeId),
modelOptions = modelChoices[node.nodeId] ?: emptyList(),
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) },
onRandomSeedToggle = { enabled -> onRandomSeedToggle(node.nodeId, enabled) },
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())
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 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 imageBytes by mutableStateOf<Map<String, ByteArray>>(emptyMap())
private var isLoadingImages by mutableStateOf(false)
@@ -607,12 +669,23 @@ private class WorkflowRunScreenModel(
private var wsStateJob: Job? = null
val galleryImages: List<GalleryImage>
get() = imageRefs.map { ref ->
GalleryImage(
filename = ref.filename,
bitmap = imageBitmaps[ref.filename],
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
)
get() {
// Filter out PreviewImage nodes, only show SaveImage outputs in gallery
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.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>
@@ -634,7 +707,8 @@ private class WorkflowRunScreenModel(
}
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
return PreviewImage(ref.filename, bytes)
}
@@ -707,6 +781,30 @@ private class WorkflowRunScreenModel(
}.map { it.nodeId }
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() {
saveJob?.cancel()
@@ -947,9 +1045,9 @@ private class WorkflowRunScreenModel(
screenModelScope.launch {
try {
val history = apiClient.getHistory(baseUrl, id)
val newRefs = extractImagesFromHistory(history)
if (newRefs.isNotEmpty() && newRefs != imageRefs) {
imageRefs = newRefs
val newRefs = extractImagesFromHistoryWithNodeId(history)
if (newRefs.isNotEmpty() && newRefs != nodeImageRefs) {
nodeImageRefs = newRefs
loadImages()
}
} catch (e: Exception) {
@@ -960,7 +1058,15 @@ private class WorkflowRunScreenModel(
private fun loadImages() {
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)) {
screenModelScope.launch {
try {
@@ -969,11 +1075,16 @@ private class WorkflowRunScreenModel(
imageBytes = imageBytes + (ref.filename to bytes)
val bitmap = bytes.decodeToImageBitmap()
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) {
// Image loading failed
} finally {
// Check if all images are loaded
if (imageBitmaps.size >= imageRefs.size) {
if (imageBitmaps.size >= nodeImageRefs.size) {
isLoadingImages = false
}
}
@@ -981,13 +1092,14 @@ private class WorkflowRunScreenModel(
}
}
// If all images are already cached
if (imageRefs.all { imageBitmaps.containsKey(it.filename) }) {
if (nodeImageRefs.all { imageBitmaps.containsKey(it.imageRef.filename) }) {
isLoadingImages = false
}
}
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
screenModelScope.launch {
try {
@@ -1000,7 +1112,8 @@ private class WorkflowRunScreenModel(
}
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 current = workflow ?: return
@@ -1014,6 +1127,16 @@ private class WorkflowRunScreenModel(
workflow = updated
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) {
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
}