fix(ios): save image & image display size
CI / android (push) Successful in 18m25s

This commit is contained in:
anran
2026-01-30 03:58:16 +08:00
parent d4a1923b97
commit d8bae1f027
7 changed files with 341 additions and 211 deletions
+1 -1
View File
@@ -23,6 +23,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
linkerOpts("-framework", "Photos")
}
}
@@ -94,4 +95,3 @@ android {
dependencies {
debugImplementation(libs.compose.uiTooling)
}
@@ -0,0 +1,10 @@
package moe.uni.comfy_kmp.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual suspend fun saveImageToGallery(bytes: ByteArray, filenameHint: String): String {
return withContext(Dispatchers.IO) {
saveToTemp(bytes, filenameHint)
}
}
@@ -0,0 +1,3 @@
package moe.uni.comfy_kmp.data
expect suspend fun saveImageToGallery(bytes: ByteArray, filenameHint: String): String
@@ -7,6 +7,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -25,14 +27,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -49,6 +48,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
@@ -78,7 +79,7 @@ import moe.uni.comfy_kmp.data.extractImagesFromHistory
import moe.uni.comfy_kmp.data.extractPromptObject
import moe.uni.comfy_kmp.data.parsePromptNodes
import moe.uni.comfy_kmp.data.saveCoverImage
import moe.uni.comfy_kmp.data.saveToTemp
import moe.uni.comfy_kmp.data.saveImageToGallery
import moe.uni.comfy_kmp.data.updateNodeInput
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.network.ComfyApiClient
@@ -127,92 +128,123 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
)
val comfyColors = MaterialTheme.comfyColors
val sheetPeekHeight = 120.dp
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = 120.dp,
sheetContainerColor = comfyColors.cardBackground,
sheetContentColor = MaterialTheme.colorScheme.onSurface,
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetDragHandle = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
},
sheetContent = {
NodeListSheet(
nodes = model.orderedNodeStates,
randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices,
loadingModels = model.loadingModels,
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) },
modifier = Modifier.navigationBarsPadding()
)
},
topBar = {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText,
progress = model.progress,
progressText = model.progressText,
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() }
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current
val layoutHeightPx = with(density) { maxHeight.toPx() }
val peekHeightPx = with(density) { sheetPeekHeight.toPx() }
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = sheetPeekHeight,
sheetContainerColor = comfyColors.cardBackground,
sheetContentColor = MaterialTheme.colorScheme.onSurface,
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetDragHandle = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
},
sheetContent = {
NodeListSheet(
nodes = model.orderedNodeStates,
randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices,
loadingModels = model.loadingModels,
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) },
modifier = Modifier.navigationBarsPadding()
)
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) }
},
topBar = {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText,
progress = model.progress,
progressText = model.progressText
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
val layoutDirection = LocalLayoutDirection.current
val startPadding = padding.calculateLeftPadding(layoutDirection)
val endPadding = padding.calculateRightPadding(layoutDirection)
val contentPadding = PaddingValues(
start = startPadding,
top = padding.calculateTopPadding(),
end = endPadding,
bottom = 0.dp
)
val sheetOffsetPx = runCatching {
scaffoldState.bottomSheetState.requireOffset()
}.getOrElse { (layoutHeightPx - peekHeightPx).coerceAtLeast(0f) }
val sheetHeightPx = (layoutHeightPx - sheetOffsetPx).coerceAtLeast(0f)
val sheetHeightOffset = with(density) { sheetHeightPx.toDp() }
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
onSetCoverClick = { index -> model.setCoverImage(index) },
modifier = Modifier.fillMaxSize()
)
)
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
onSetCoverClick = { index -> model.setCoverImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
)
WorkflowRunFab(
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() },
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(end = ComfySpacing.lg, bottom = ComfySpacing.lg)
.offset(y = -sheetHeightOffset)
)
}
}
}
}
@@ -230,10 +262,7 @@ private fun WorkflowRunTopBar(
status: ExecutionStatus,
statusText: String,
progress: Float?,
progressText: String?,
isRunning: Boolean,
onStart: () -> Unit,
onInterrupt: () -> Unit
progressText: String?
) {
Column(
modifier = Modifier
@@ -250,43 +279,6 @@ private fun WorkflowRunTopBar(
)
}
},
actions = {
AnimatedContent(
targetState = isRunning,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { running ->
if (running) {
OutlinedButton(
onClick = onInterrupt,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("中断")
}
} else {
FilledTonalButton(
onClick = onStart,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("开始执行")
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
@@ -301,6 +293,51 @@ private fun WorkflowRunTopBar(
}
}
@Composable
private fun WorkflowRunFab(
isRunning: Boolean,
onStart: () -> Unit,
onInterrupt: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier) {
AnimatedContent(
targetState = isRunning,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { running ->
if (running) {
ExtendedFloatingActionButton(
onClick = onInterrupt,
icon = {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
text = { Text("中断") },
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
} else {
ExtendedFloatingActionButton(
onClick = onStart,
icon = {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
text = { Text("开始执行") },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
@Composable
private fun NodeListSheet(
nodes: List<NodeExecutionState>,
@@ -719,8 +756,8 @@ private class WorkflowRunScreenModel(
val bytes = imageBytes[ref.filename] ?: return
screenModelScope.launch {
try {
val path = saveToTemp(bytes, ref.filename)
statusText = "已保存: $path"
val location = saveImageToGallery(bytes, ref.filename)
statusText = "已保存: $location"
} catch (e: Exception) {
statusText = "保存失败: ${e.message}"
}
@@ -0,0 +1,74 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package moe.uni.comfy_kmp.data
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSData
import platform.Foundation.dataWithBytes
import platform.Photos.PHAccessLevelAddOnly
import platform.Photos.PHAssetChangeRequest
import platform.Photos.PHAuthorizationStatusAuthorized
import platform.Photos.PHAuthorizationStatusLimited
import platform.Photos.PHAuthorizationStatusNotDetermined
import platform.Photos.PHPhotoLibrary
import platform.UIKit.UIImage
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
actual suspend fun saveImageToGallery(bytes: ByteArray, filenameHint: String): String {
return suspendCancellableCoroutine { continuation ->
val image: UIImage? = UIImage(data = bytes.toNSData())
if (image == null) {
continuation.resumeWithException(IllegalArgumentException("Unable to decode image"))
return@suspendCancellableCoroutine
}
fun finishSuccess() {
if (continuation.isActive) {
continuation.resume("Photos")
}
}
fun finishError(message: String) {
if (continuation.isActive) {
continuation.resumeWithException(IllegalStateException(message))
}
}
fun saveToAlbum() {
PHPhotoLibrary.sharedPhotoLibrary().performChanges({
PHAssetChangeRequest.creationRequestForAssetFromImage(image)
}, completionHandler = { success, error ->
if (success) {
finishSuccess()
} else {
val details = error?.localizedDescription?.let { ": $it" }.orEmpty()
finishError("Failed to save to Photos$details")
}
})
}
val status = PHPhotoLibrary.authorizationStatusForAccessLevel(PHAccessLevelAddOnly)
when (status) {
PHAuthorizationStatusAuthorized, PHAuthorizationStatusLimited -> saveToAlbum()
PHAuthorizationStatusNotDetermined -> {
PHPhotoLibrary.requestAuthorizationForAccessLevel(PHAccessLevelAddOnly) { newStatus ->
if (newStatus == PHAuthorizationStatusAuthorized || newStatus == PHAuthorizationStatusLimited) {
saveToAlbum()
} else {
finishError("Photo Library access not granted")
}
}
}
else -> finishError("Photo Library access not granted")
}
}
}
private fun ByteArray.toNSData(): NSData {
return usePinned { pinned ->
NSData.dataWithBytes(pinned.addressOf(0), size.toULong())
}
}