@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-14
@@ -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,13 +669,24 @@ private class WorkflowRunScreenModel(
|
||||
private var wsStateJob: Job? = null
|
||||
|
||||
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(
|
||||
filename = ref.filename,
|
||||
bitmap = imageBitmaps[ref.filename],
|
||||
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val orderedNodeStates: List<NodeExecutionState>
|
||||
get() {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -708,6 +782,30 @@ private class WorkflowRunScreenModel(
|
||||
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()
|
||||
saveJob = screenModelScope.launch {
|
||||
@@ -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
|
||||
|
||||
@@ -1015,6 +1128,16 @@ private class WorkflowRunScreenModel(
|
||||
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 ->
|
||||
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