Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
# Image Studio

> 开源图像生成 / 编辑客户端 · Wails(Go + React/TS) 桌面端 + Android WebView 壳层 ·
> 支持 Responses API SSE 保活与标准 Images API
> 支持 Responses API SSE / WebSocket mode 与标准 Images API

![license](https://img.shields.io/badge/license-AGPLv3-b22222)
![go](https://img.shields.io/badge/go-%3E%3D1.25-00ADD8)
![react](https://img.shields.io/badge/react-18-61DAFB)
![wails](https://img.shields.io/badge/wails-v2.12-DF0000)
![platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20android-lightgrey)

Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推理在 Cloudflare / Nginx 后面容易遇到的 524/504 断连问题。Responses API 模式通过 SSE 持续接收事件来保持连接活跃;Images API 模式则兼容标准 `/v1/images/generations` 与 `/v1/images/edits`。
Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推理在 Cloudflare / Nginx 后面容易遇到的 524/504 断连问题。Responses API 模式支持 `HTTP SSE` 与 `WebSocket mode` 两种传输;Images API 模式则兼容标准 `/v1/images/generations` 与 `/v1/images/edits`。

项目不内置任何默认上游。首次启动需要你自己填写 BASE_URL、API Key、文本模型与图像模型。

Expand Down
1 change: 1 addition & 0 deletions android-shell/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.webkit:webkit:1.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250517")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,20 @@ import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.math.max
import kotlin.math.roundToInt
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.Response

class AndroidImageStudioBridge(
private val context: Context,
Expand All @@ -53,6 +61,7 @@ class AndroidImageStudioBridge(
private var pendingOpenImageRequestId: String? = null
private var pendingImportHistoryRequestId: String? = null
private val httpRequests = ConcurrentHashMap<String, HttpURLConnection>()
private val webSocketRequests = ConcurrentHashMap<String, WebSocket>()
@Volatile private var fullscreen = false

companion object {
Expand Down Expand Up @@ -124,6 +133,10 @@ class AndroidImageStudioBridge(
val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少 HTTP 请求参数")
runHttpRequestText(requestId, payload)
}
"ResponsesWebSocketRequest" -> {
val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少 WebSocket 请求参数")
runResponsesWebSocketRequest(requestId, payload)
}
"ProbeUpstream" -> {
val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少测活参数")
runProbeUpstream(requestId, payload)
Expand All @@ -132,6 +145,10 @@ class AndroidImageStudioBridge(
cancelHttpRequest(args.optString(0))
null
}
"CancelWebSocketRequest" -> {
cancelWebSocketRequest(args.optString(0))
null
}
"Vibrate" -> {
vibrate(args.optLong(0, 50L))
null
Expand Down Expand Up @@ -514,6 +531,8 @@ class AndroidImageStudioBridge(
val apiKey = payload.optString("apiKey").trim()
val proxyMode = payload.optString("proxyMode", "system")
val proxyUrl = payload.optString("proxyURL", "")
val apiMode = payload.optString("apiMode", "responses")
val responsesTransport = payload.optString("responsesTransport", "sse")
if (apiKey.isBlank()) throw IllegalArgumentException("API Key 不能为空")
thread(name = "image-studio-probe-${requestId.take(12)}") {
try {
Expand Down Expand Up @@ -553,7 +572,36 @@ class AndroidImageStudioBridge(
"displayName" to item.optString("name").trim(),
)
}
resolve(requestId, mapOf("modelCount" to data.length(), "models" to models))
val result = mutableMapOf<String, Any>(
"modelCount" to data.length(),
"models" to models,
)
if (apiMode == "responses" && responsesTransport == "websocket") {
result["responsesTransport"] = "websocket"
val probePayload = JSONObject()
.put("type", "response.create")
.put("model", "gpt-5.5")
.put("store", false)
.put("generate", false)
.put("input", JSONArray().put(
JSONObject()
.put("role", "user")
.put("content", JSONArray().put(
JSONObject()
.put("type", "input_text")
.put("text", "health check")
))
))
.put("tools", JSONArray())
try {
probeResponsesWebSocket(baseUrl, apiKey, probePayload.toString(), proxyMode, proxyUrl)
result["responsesTransportOK"] = true
} catch (wsError: Exception) {
result["responsesTransportOK"] = false
result["responsesTransportError"] = wsError.message ?: wsError.javaClass.simpleName
}
}
resolve(requestId, result)
} catch (error: Exception) {
reject(requestId, error.message ?: error.javaClass.simpleName)
}
Expand Down Expand Up @@ -607,6 +655,192 @@ class AndroidImageStudioBridge(
httpRequests.remove(requestKey)?.disconnect()
}

private fun cancelWebSocketRequest(requestKey: String) {
webSocketRequests.remove(requestKey)?.cancel()
}

private fun runResponsesWebSocketRequest(requestId: String, payload: JSONObject): Nothing {
val requestKey = payload.optString("requestKey").ifBlank { requestId }
val baseUrl = validateProbeBaseUrl(payload.optString("baseURL"))
val apiKey = payload.optString("apiKey").trim()
val proxyMode = payload.optString("proxyMode", "system")
val proxyUrl = payload.optString("proxyURL", "")
val payloadText = payload.optString("payload")
if (apiKey.isBlank()) throw IllegalArgumentException("API Key 不能为空")
if (payloadText.isBlank()) throw IllegalArgumentException("WebSocket payload 不能为空")
val wsUrl = responsesWebSocketUrl(baseUrl)
val client = okHttpClientForWebSocket(proxyMode, proxyUrl)
val rawLines = mutableListOf<String>()
val streamResult = arrayOfNulls<NativeHttpStreamResultSnapshot>(1)
val settled = java.util.concurrent.atomic.AtomicBoolean(false)
val socket = client.newWebSocket(
Request.Builder()
.url(wsUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("User-Agent", "image-studio-android")
.build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocketRequests[requestKey] = webSocket
webSocket.send(payloadText)
}

override fun onMessage(webSocket: WebSocket, text: String) {
val line = text.trim()
if (line.isBlank()) return
rawLines += "data: $line"
if (streamResult[0] == null) {
streamResult[0] = extractNativeHttpStreamResult(line)
}
buildNativeHttpStreamProgressPayload(line)?.let { progressPayload ->
emitNativeProgress(requestKey, progressPayload)
}
try {
val parsed = JSONObject(line)
when (parsed.optString("type")) {
"response.completed" -> {
if (settled.compareAndSet(false, true)) {
val rawBody = rawLines.joinToString("\n")
val rawPath = if (rawBody.isNotBlank()) writeNativeHttpRawBody(rawBody, "text/plain", requestKey) else ""
resolve(requestId, mapOf(
"status" to 200,
"body" to "",
"contentType" to "text/event-stream",
"rawPath" to rawPath,
"resultImageB64" to (streamResult[0]?.imageB64 ?: ""),
"revisedPrompt" to (streamResult[0]?.revisedPrompt ?: ""),
"sourceEvent" to (streamResult[0]?.sourceEvent ?: "final"),
))
webSocket.close(1000, "completed")
webSocketRequests.remove(requestKey)
}
}
"error" -> {
if (settled.compareAndSet(false, true)) {
reject(requestId, line)
webSocket.cancel()
webSocketRequests.remove(requestKey)
}
}
}
} catch (_: Exception) {
}
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
webSocketRequests.remove(requestKey)
if (settled.compareAndSet(false, true)) {
reject(requestId, t.message ?: "websocket failure")
}
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
webSocketRequests.remove(requestKey)
if (settled.compareAndSet(false, true)) {
if (streamResult[0]?.imageB64?.isNotBlank() == true) {
val rawBody = rawLines.joinToString("\n")
val rawPath = if (rawBody.isNotBlank()) writeNativeHttpRawBody(rawBody, "text/plain", requestKey) else ""
resolve(requestId, mapOf(
"status" to 200,
"body" to "",
"contentType" to "text/event-stream",
"rawPath" to rawPath,
"resultImageB64" to (streamResult[0]?.imageB64 ?: ""),
"revisedPrompt" to (streamResult[0]?.revisedPrompt ?: ""),
"sourceEvent" to (streamResult[0]?.sourceEvent ?: "final"),
))
} else {
reject(requestId, if (reason.isNotBlank()) reason else "websocket closed before final image")
}
}
}
}
)
webSocketRequests[requestKey] = socket
throw EarlyResolve()
}

private fun responsesWebSocketUrl(baseUrl: String): String {
val uri = URI(baseUrl)
val scheme = when (uri.scheme?.lowercase(Locale.US)) {
"https" -> "wss"
"http" -> "ws"
else -> throw IllegalArgumentException("BASE_URL 仅支持 http:// 或 https://")
}
val path = (uri.path ?: "").trimEnd('/')
val normalizedPath = if (path.isBlank()) "/v1/responses" else "$path/v1/responses"
return URI(scheme, uri.userInfo, uri.host, uri.port, normalizedPath, null, null).toString()
}

private fun okHttpClientForWebSocket(proxyMode: String, proxyUrl: String): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(generationConnectTimeoutMs.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS)
.pingInterval(25, java.util.concurrent.TimeUnit.SECONDS)
when (normalizeProxyMode(proxyMode)) {
"none" -> builder.proxy(Proxy.NO_PROXY)
"custom" -> builder.proxy(parseCustomProxy(proxyUrl))
}
return builder.build()
}

private fun probeResponsesWebSocket(
baseUrl: String,
apiKey: String,
payload: String,
proxyMode: String,
proxyUrl: String,
) {
val wsUrl = responsesWebSocketUrl(baseUrl)
val client = okHttpClientForWebSocket(proxyMode, proxyUrl)
val done = CountDownLatch(1)
val errorRef = AtomicReference<String?>(null)
val socket = client.newWebSocket(
Request.Builder()
.url(wsUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("User-Agent", "image-studio-android")
.build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(payload)
}

override fun onMessage(webSocket: WebSocket, text: String) {
try {
val parsed = JSONObject(text)
when (parsed.optString("type")) {
"response.created", "response.completed" -> {
webSocket.close(1000, "probe completed")
done.countDown()
}
"error" -> {
errorRef.set(text)
webSocket.cancel()
done.countDown()
}
}
} catch (_: Exception) {
}
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorRef.set(t.message ?: "websocket failure")
done.countDown()
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
done.countDown()
}
}
)
if (!done.await(20, TimeUnit.SECONDS)) {
socket.cancel()
throw IllegalStateException("Responses WebSocket 探测超时")
}
errorRef.get()?.let { throw IllegalStateException(it) }
}

private fun validateProbeBaseUrl(raw: String): String {
val cleaned = raw.trim().trimEnd('/')
if (cleaned.isBlank()) throw IllegalArgumentException("未配置上游 BASE_URL")
Expand Down
51 changes: 44 additions & 7 deletions docs/batch-img2img/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,22 @@

## 使用流程

1. 选择输入文件夹。
2. 应用扫描当前目录中的图片文件。
3. 确认扫描数量和示例文件名。
4. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。
5. 选择输出位置。
1. 选择输入文件夹并扫描当前目录中的图片文件。
或者直接选择多张图片,把它们加入当前批处理队列。
2. 确认当前批处理队列中的图片数量和示例文件名。
3. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。
4. 选择输出位置。
- 默认保存回原目录:每张结果回到对应源图所在目录。
- 独立输出路径:所有结果统一保存到你选定的目录。
6. 设置并发数。
7. 点击 `编辑` 开始处理。
5. 设置并发数。
6. 点击 `编辑` 开始处理。

## 输入规则

当前版本只处理你选择的这一层目录,不递归子目录。

如果你不想走目录模式,也可以直接选择多张图片加入批处理队列。

支持的输入图片格式:

- `png`
Expand Down Expand Up @@ -89,6 +91,41 @@
- 实际可并发数量仍受当前上游 profile 的并发限制约束。
- 如果并发开得太高,应用可能会自动关闭流式预览,优先保证最终图完整。

## 不同比例源图的处理

如果一个目录里的源图宽高比不一致,但你仍然希望:

- 所有图片使用相同提示词
- 所有图片使用同一档分辨率
- 每张图仍然尽量保持各自源图的比例

可以在批处理面板里开启:

`按源图比例自动适配`

然后再选择统一的分辨率档位,例如 `1K`、`2K`、`4K`。

这时应用会对每一张源图分别计算:

- 比例:取该源图自身宽高比
- 分辨率:取你在批处理面板里指定的统一档位,并沿用应用当前对 `1K / 2K / 4K` 的尺寸换算规则

例如:

- 横图会得到横向尺寸
- 竖图会得到纵向尺寸
- 它们都仍然共享同一个提示词和同一个分辨率档位

这里的 `1K / 2K / 4K` 仍然遵循应用当前的尺寸语义:

- 内置比例会落到内置尺寸矩阵
- 非内置比例会按该档位的像素面积参考值推导具体宽高

注意:

- 这是批处理模式下的额外尺寸策略。
- 它不会改变普通图生图里 `Auto` 比例与 `size=auto` 的原有语义。

## 参数继承

批处理会复用你当前图生图控制面板里的参数,包括:
Expand Down
Loading
Loading