diff --git a/composeApp/src/androidMain/kotlin/moe/uni/comfyKmp/ImagePicker.android.kt b/composeApp/src/androidMain/kotlin/moe/uni/comfyKmp/ImagePicker.android.kt new file mode 100644 index 0000000..d3467ad --- /dev/null +++ b/composeApp/src/androidMain/kotlin/moe/uni/comfyKmp/ImagePicker.android.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ImagePicker.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ImagePicker.kt new file mode 100644 index 0000000..6d77b4c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ImagePicker.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/HistoryUtils.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/HistoryUtils.kt index aaab100..47b1b3e 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/HistoryUtils.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/HistoryUtils.kt @@ -6,21 +6,28 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -fun extractImagesFromHistory(history: JsonElement): List { - val result = mutableListOf() +/** + * Extract images from history with their associated node IDs + */ +fun extractImagesFromHistoryWithNodeId(history: JsonElement): List { + val result = mutableListOf() 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 { + return extractImagesFromHistoryWithNodeId(history).map { it.imageRef } +} diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/Models.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/Models.kt index a3fb620..b141895 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/Models.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/data/Models.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/network/ComfyApiClient.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/network/ComfyApiClient.kt index aaeb80b..9d002d1 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/network/ComfyApiClient.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/network/ComfyApiClient.kt @@ -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() + } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/NodeCard.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/NodeCard.kt index 2e19064..eeecb59 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/NodeCard.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/NodeCard.kt @@ -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 = 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, + 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, + 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, + 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 + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt index 483fadf..4b43a49 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt @@ -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(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(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, modelChoices: Map>, loadingModels: Set, + uploadingImageNodes: Set, + uploadedImagePreviews: Map, + nodeOutputPreviews: Map, + 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>(emptySet()) private set + // Image upload state + var uploadingImageNodes by mutableStateOf>(emptySet()) + private set + var uploadedImagePreviews by mutableStateOf>(emptyMap()) + private set + + // Node output preview images (for PreviewImage nodes) + var nodeOutputPreviews by mutableStateOf>(emptyMap()) + private set + private var promptId by mutableStateOf(null) - private var imageRefs by mutableStateOf>(emptyList()) + private var nodeImageRefs by mutableStateOf>(emptyList()) private var imageBitmaps by mutableStateOf>(emptyMap()) private var imageBytes by mutableStateOf>(emptyMap()) private var isLoadingImages by mutableStateOf(false) @@ -607,12 +669,23 @@ private class WorkflowRunScreenModel( private var wsStateJob: Job? = null val galleryImages: List - 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 @@ -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 { + 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 -> diff --git a/composeApp/src/iosMain/kotlin/moe/uni/comfyKmp/ImagePicker.ios.kt b/composeApp/src/iosMain/kotlin/moe/uni/comfyKmp/ImagePicker.ios.kt new file mode 100644 index 0000000..77cab0f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/moe/uni/comfyKmp/ImagePicker.ios.kt @@ -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 +}