@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+137
-14
@@ -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,13 +669,24 @@ 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() {
|
||||||
|
// 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(
|
GalleryImage(
|
||||||
filename = ref.filename,
|
filename = ref.filename,
|
||||||
bitmap = imageBitmaps[ref.filename],
|
bitmap = imageBitmaps[ref.filename],
|
||||||
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
|
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val orderedNodeStates: List<NodeExecutionState>
|
val orderedNodeStates: List<NodeExecutionState>
|
||||||
get() {
|
get() {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -708,6 +782,30 @@ private class WorkflowRunScreenModel(
|
|||||||
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()
|
||||||
saveJob = screenModelScope.launch {
|
saveJob = screenModelScope.launch {
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -1015,6 +1128,16 @@ private class WorkflowRunScreenModel(
|
|||||||
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 ->
|
||||||
if (state.nodeId == nodeId) state.copy(status = status)
|
if (state.nodeId == nodeId) state.copy(status = status)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user