From 4307ecae2d9a81883d167a97b1bf43168bb7cc2e Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 14:57:22 +0800
Subject: [PATCH 01/11] Fix canvas image navigation by createdAt
---
.../src/components/canvas/CanvasStage.tsx | 11 +++++++----
image-studio/frontend/src/lib/images.ts | 18 ++++++++++++++++++
.../android/canvas/AndroidCanvasStage.tsx | 11 +++++++----
.../frontend/src/state/studioStore.media.ts | 16 +++++++++++-----
4 files changed, 43 insertions(+), 13 deletions(-)
diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx
index e0276f5e7..eec4c3cff 100644
--- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx
+++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx
@@ -14,7 +14,7 @@ import { AnnotationShape } from "./AnnotationShape";
import { useCanvasShortcuts } from "./useCanvasShortcuts";
import { StreamPreviewBadge } from "./StreamPreviewBadge";
import { streamPreviewItemsFromPreviews } from "../../state/studioStore.streamPreview";
-import { historyFullSrc } from "../../lib/images";
+import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../lib/images";
import { DragExportHandle } from "./DragExportHandle";
export function CanvasStage() {
@@ -34,6 +34,7 @@ export function CanvasStage() {
jobsTotal,
jobsCompleted,
toggleFullscreen,
+ history,
batchResults, resultGridOpen, selectBatchResult, closeResultGrid,
canvasViewResetTick,
stepBatchResult,
@@ -48,6 +49,8 @@ export function CanvasStage() {
outputFormat: useStudioStore.getState().outputFormat,
currentImage,
});
+ const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults);
+ const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults);
const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length);
const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` }));
for (const item of batchResults) {
@@ -62,8 +65,8 @@ export function CanvasStage() {
}
const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1;
const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1);
- const currentBatchIndex = currentImage ? batchResults.findIndex((item) => item.id === currentImage.id) : -1;
- const canNavigateBatchResults = currentBatchIndex >= 0 && batchResults.length > 1;
+ const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1;
+ const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1;
// Hold-space-for-pan: while space is held, override tool to "pan".
const [spacePan, setSpacePan] = useState(false);
@@ -465,7 +468,7 @@ export function CanvasStage() {
) : null}
{showingResultGrid && (
(items: T[]): T[] {
+ return [...items].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
+}
+
+export function orderedNavigationItemsForCurrent(
+ currentId: string | null | undefined,
+ historyItems: T[],
+ batchItems: T[],
+): T[] {
+ const orderedBatchItems = sortHistoryItemsByCreatedAtAsc(batchItems);
+ if (currentId && orderedBatchItems.some((item) => item.id === currentId)) return orderedBatchItems;
+
+ const orderedHistoryItems = sortHistoryItemsByCreatedAtAsc(historyItems);
+ if (currentId && orderedHistoryItems.some((item) => item.id === currentId)) return orderedHistoryItems;
+
+ return orderedBatchItems.length > 1 ? orderedBatchItems : orderedHistoryItems;
+}
+
export function isTransientPreviewItem(
item: {
id?: string | null;
diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
index b6ddacefd..db63f4af2 100644
--- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
+++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
@@ -10,7 +10,7 @@ import { CompareOverlay } from "../../../components/canvas/CompareOverlay";
import { copyImageB64ToClipboard, copyImageURLToClipboard, useImageFromSource } from "../../../components/canvas/canvasImage";
import { StreamPreviewBadge } from "../../../components/canvas/StreamPreviewBadge";
import { useCanvasShortcuts } from "../../../components/canvas/useCanvasShortcuts";
-import { historyFullSrc } from "../../../lib/images";
+import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../../lib/images";
import { streamPreviewItemsFromPreviews } from "../../../state/studioStore.streamPreview";
import { vibrateForPlatform } from "../bridge";
@@ -43,6 +43,7 @@ export function AndroidCanvasStage() {
jobsCompleted,
activeWorkspaceId,
toggleFullscreen,
+ history,
batchResults, resultGridOpen, selectBatchResult, closeResultGrid,
canvasViewResetTick,
stepBatchResult,
@@ -56,6 +57,8 @@ export function AndroidCanvasStage() {
outputFormat: useStudioStore.getState().outputFormat,
currentImage,
});
+ const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults);
+ const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults);
const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length);
const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` }));
for (const item of batchResults) {
@@ -70,8 +73,8 @@ export function AndroidCanvasStage() {
}
const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1;
const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1);
- const currentBatchIndex = currentImage ? batchResults.findIndex((item) => item.id === currentImage.id) : -1;
- const canNavigateBatchResults = currentBatchIndex >= 0 && batchResults.length > 1;
+ const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1;
+ const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1;
const stageRef = useRef(null);
const roRef = useRef(null);
@@ -463,7 +466,7 @@ export function AndroidCanvasStage() {
>
{showingResultGrid ? (
item.id === state.currentImage?.id);
+ if (!state.currentImage) return;
+ const navigationItems = orderedNavigationItemsForCurrent(
+ state.currentImage.id,
+ state.history,
+ state.batchResults,
+ );
+ if (navigationItems.length <= 1) return;
+ const currentIndex = navigationItems.findIndex((item) => item.id === state.currentImage?.id);
if (currentIndex < 0) return;
- const nextIndex = (currentIndex + delta + state.batchResults.length) % state.batchResults.length;
- const nextItem = state.batchResults[nextIndex];
+ const nextIndex = (currentIndex + delta + navigationItems.length) % navigationItems.length;
+ const nextItem = navigationItems[nextIndex];
if (!nextItem) return;
await store.getState().selectBatchResult(nextItem);
},
From 508e72cfc61bf9ee233fdfeb3ef14dabbd9447b9 Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 17:01:47 +0800
Subject: [PATCH 02/11] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/panel/SettingsPanel.tsx | 69 +++++++++++++-
image-studio/frontend/src/lib/compatState.ts | 12 +++
.../src/lib/completionNotification.ts | 86 ++++++++++++++++++
.../frontend/src/state/studioStore.ts | 55 +++++++++++-
.../frontend/src/state/studioStore.types.ts | 6 ++
image-studio/frontend/src/types/domain.ts | 5 ++
.../test/completionNotification.test.mjs | 90 +++++++++++++++++++
7 files changed, 318 insertions(+), 5 deletions(-)
create mode 100644 image-studio/frontend/src/lib/completionNotification.ts
create mode 100644 image-studio/frontend/test/completionNotification.test.mjs
diff --git a/image-studio/frontend/src/components/panel/SettingsPanel.tsx b/image-studio/frontend/src/components/panel/SettingsPanel.tsx
index 453973055..7fd76f7fa 100644
--- a/image-studio/frontend/src/components/panel/SettingsPanel.tsx
+++ b/image-studio/frontend/src/components/panel/SettingsPanel.tsx
@@ -7,7 +7,7 @@ import { useStudioStore } from "../../state/studioStore";
import {
GetOutputDir, OpenOutputDir, OpenExternalURL, ChooseOutputDir, SetOutputDir,
} from "../../platform/runtime/host";
-import type { KernelRuntimeMode, ProxyMode } from "../../types/domain";
+import type { KernelRuntimeMode, ProxyMode, SystemNotificationPermissionState } from "../../types/domain";
import { Modal } from "../common/Modal";
import { rememberTrustedOutputRoot } from "../../lib/storage";
import { scheduleCompatibilityExport } from "../../lib/compatState";
@@ -45,11 +45,15 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
keepLogs, setKeepLogs,
cleanupPreviewCacheOnExit, setCleanupPreviewCacheOnExit,
completionSound,
+ completionNotification,
+ completionNotificationPermission,
setCompletionSoundEnabled,
setCompletionSoundMode,
setCompletionSoundCustom,
resetCompletionSoundCustom,
previewCompletionSound,
+ setCompletionNotificationEnabled,
+ requestCompletionNotificationPermission,
} = useStudioStore();
const [outputDir, setOutputDir] = useState("");
@@ -138,6 +142,47 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
input.click();
}
+ function systemNotificationPermissionLabel(permission: SystemNotificationPermissionState): string {
+ if (permission === "granted") return "已允许";
+ if (permission === "denied") return "已拒绝";
+ if (permission === "unsupported") return "当前平台不支持";
+ return "尚未授权";
+ }
+
+ async function updateCompletionNotificationEnabled(value: boolean) {
+ const permission = await setCompletionNotificationEnabled(value);
+ if (!value) {
+ pushToast("已关闭系统通知", "success");
+ return;
+ }
+ if (permission === "granted") {
+ pushToast("已开启系统通知", "success");
+ return;
+ }
+ if (permission === "unsupported") {
+ pushToast("当前平台不支持系统通知", "warn", 5000);
+ return;
+ }
+ if (permission === "denied") {
+ pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000);
+ return;
+ }
+ pushToast("请先允许系统通知权限", "warn");
+ }
+
+ async function askCompletionNotificationPermission() {
+ const permission = await requestCompletionNotificationPermission();
+ if (permission === "granted") {
+ pushToast("系统通知权限已授权", "success");
+ } else if (permission === "denied") {
+ pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000);
+ } else if (permission === "unsupported") {
+ pushToast("当前平台不支持系统通知", "warn", 5000);
+ } else {
+ pushToast("系统通知权限仍未授权", "warn");
+ }
+ }
+
function closeSettings() {
setAboutOpen(false);
onClose();
@@ -475,6 +520,28 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
+
+
+ void updateCompletionNotificationEnabled(true)}>
+ 开启
+
+ void updateCompletionNotificationEnabled(false)}>
+ 关闭
+
+
+
+
+
+
+ 整批任务全部完成后,仅在窗口不在前台时发送系统通知。当前权限:{systemNotificationPermissionLabel(completionNotificationPermission)}。
+
+
+
setTheme("system")}>
diff --git a/image-studio/frontend/src/lib/compatState.ts b/image-studio/frontend/src/lib/compatState.ts
index 6bbed1dcf..4f45ff4b8 100644
--- a/image-studio/frontend/src/lib/compatState.ts
+++ b/image-studio/frontend/src/lib/compatState.ts
@@ -2,6 +2,7 @@ import { LoadCompatibilityState, SaveCompatibilityState } from "../platform/runt
import type {
BatchProcessConfig,
BackgroundValue,
+ CompletionNotificationConfig,
CustomAspectRatio,
EditSourceMode,
CompletionSoundConfig,
@@ -33,6 +34,10 @@ import {
normalizeCompletionSoundConfig,
persistCompletionSoundConfig,
} from "./completionSound";
+import {
+ normalizeCompletionNotificationConfig,
+ persistCompletionNotificationConfig,
+} from "./completionNotification";
import { normalizePromptTemplates } from "./promptTemplates";
const SCHEMA_VERSION = 1;
@@ -69,6 +74,7 @@ export type CompatibilityState = {
cleanupPreviewCacheOnExit?: boolean;
ignoredReleaseTag?: string;
completionSound?: CompletionSoundConfig;
+ completionNotification?: CompletionNotificationConfig;
};
profiles: UpstreamProfile[];
activeProfileId: string;
@@ -103,6 +109,7 @@ export type CompatibilityExportInput = {
cleanupPreviewCacheOnExit: boolean;
ignoredReleaseTag: string;
completionSound: CompletionSoundConfig;
+ completionNotification: CompletionNotificationConfig;
};
let exportTimer: ReturnType | null = null;
@@ -162,6 +169,7 @@ export function compatibilityExportFingerprint(input: CompatibilityExportInput):
cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit,
ignoredReleaseTag: input.ignoredReleaseTag,
completionSound: input.completionSound,
+ completionNotification: input.completionNotification,
outputDir: readLocalStorageString("gptcodex.outputDir"),
trustedOutputRoots: loadTrustedOutputRoots(),
savePromptSuppressed: readLocalStorageString("gptcodex.savePromptSuppressed") === "1",
@@ -202,6 +210,7 @@ function buildCompatibilityState(input: CompatibilityExportInput): Compatibility
cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit === true,
ignoredReleaseTag: readLocalStorageString("gptcodex.ignoredReleaseTag"),
completionSound: normalizeCompletionSoundConfig(input.completionSound),
+ completionNotification: normalizeCompletionNotificationConfig(input.completionNotification),
},
profiles: normalizeProfiles(input.profiles),
activeProfileId: input.activeProfileId || "",
@@ -254,6 +263,7 @@ function applyCompatibilityLocalStorage(state: CompatibilityState): void {
removeLocalStorage("gptcodex.ignoredReleaseTag");
}
persistCompletionSoundConfig(normalizeCompletionSoundConfig(settings.completionSound));
+ persistCompletionNotificationConfig(normalizeCompletionNotificationConfig(settings.completionNotification));
}
async function persistCompatibilityHistory(state: CompatibilityState): Promise {
@@ -320,6 +330,7 @@ function normalizeSettings(raw: unknown): CompatibilityState["settings"] {
cleanupPreviewCacheOnExit: source.cleanupPreviewCacheOnExit === true,
ignoredReleaseTag: typeof source.ignoredReleaseTag === "string" ? source.ignoredReleaseTag.trim() : "",
completionSound: normalizeCompletionSoundConfig(source.completionSound),
+ completionNotification: normalizeCompletionNotificationConfig(source.completionNotification),
};
}
@@ -425,6 +436,7 @@ function cloneExportInput(input: CompatibilityExportInput): CompatibilityExportI
cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit,
ignoredReleaseTag: input.ignoredReleaseTag,
completionSound: normalizeCompletionSoundConfig(input.completionSound),
+ completionNotification: normalizeCompletionNotificationConfig(input.completionNotification),
};
}
diff --git a/image-studio/frontend/src/lib/completionNotification.ts b/image-studio/frontend/src/lib/completionNotification.ts
new file mode 100644
index 000000000..ee9c7d88c
--- /dev/null
+++ b/image-studio/frontend/src/lib/completionNotification.ts
@@ -0,0 +1,86 @@
+import type {
+ CompletionNotificationConfig,
+ SystemNotificationPermissionState,
+} from "../types/domain";
+
+const COMPLETION_NOTIFICATION_ENABLED_KEY = "gptcodex.completionNotification.enabled";
+
+export function normalizeCompletionNotificationConfig(raw: unknown): CompletionNotificationConfig {
+ const source = raw && typeof raw === "object" ? raw as Record : {};
+ return {
+ enabled: source.enabled !== false,
+ };
+}
+
+export function readCompletionNotificationConfig(): CompletionNotificationConfig {
+ try {
+ return normalizeCompletionNotificationConfig({
+ enabled: localStorage.getItem(COMPLETION_NOTIFICATION_ENABLED_KEY) !== "0",
+ });
+ } catch {
+ return normalizeCompletionNotificationConfig(null);
+ }
+}
+
+export function persistCompletionNotificationConfig(value: CompletionNotificationConfig): void {
+ const next = normalizeCompletionNotificationConfig(value);
+ try {
+ localStorage.setItem(COMPLETION_NOTIFICATION_ENABLED_KEY, next.enabled ? "1" : "0");
+ } catch {
+ // localStorage can be unavailable in tests/previews.
+ }
+}
+
+export function readSystemNotificationPermission(): SystemNotificationPermissionState {
+ try {
+ if (typeof Notification === "undefined") return "unsupported";
+ if (Notification.permission === "granted") return "granted";
+ if (Notification.permission === "denied") return "denied";
+ return "default";
+ } catch {
+ return "unsupported";
+ }
+}
+
+export async function requestSystemNotificationPermission(): Promise {
+ const current = readSystemNotificationPermission();
+ if (current === "unsupported" || current === "granted") return current;
+ try {
+ if (typeof Notification === "undefined" || typeof Notification.requestPermission !== "function") {
+ return current;
+ }
+ const next = await Notification.requestPermission();
+ if (next === "granted" || next === "denied" || next === "default") return next;
+ } catch {}
+ return readSystemNotificationPermission();
+}
+
+export function shouldSendCompletionNotification(input: {
+ config: CompletionNotificationConfig;
+ completedNow: number;
+ totalNow: number;
+ windowHidden: boolean;
+}): boolean {
+ const next = normalizeCompletionNotificationConfig(input.config);
+ return next.enabled
+ && input.windowHidden
+ && input.totalNow > 0
+ && input.completedNow === input.totalNow;
+}
+
+export function showSystemNotification(title: string, body: string, onClick?: () => void): boolean {
+ if (readSystemNotificationPermission() !== "granted") return false;
+ try {
+ const notification = new Notification(title, { body });
+ if (onClick) {
+ notification.onclick = () => {
+ try { window.focus(); } catch {}
+ onClick();
+ notification.close();
+ };
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts
index f2585843a..cf599853d 100644
--- a/image-studio/frontend/src/state/studioStore.ts
+++ b/image-studio/frontend/src/state/studioStore.ts
@@ -80,6 +80,15 @@ import {
readCompletionSoundConfig,
shouldPlayCompletionSound,
} from "../lib/completionSound";
+import {
+ normalizeCompletionNotificationConfig,
+ persistCompletionNotificationConfig,
+ readCompletionNotificationConfig,
+ readSystemNotificationPermission,
+ requestSystemNotificationPermission,
+ shouldSendCompletionNotification,
+ showSystemNotification,
+} from "../lib/completionNotification";
import {
persistPromptTemplates,
readStoredPromptTemplates,
@@ -160,7 +169,6 @@ import {
ensureFullHistoryItem as ensureFullHistoryItemRuntime,
materializeHistoryItem as materializeHistoryItemRuntime,
STYLE_SUFFIXES,
- tryNotify,
withMediaAssetRef,
} from "./studioStore.runtime";
import { createMediaActions } from "./studioStore.media";
@@ -601,6 +609,8 @@ export const useStudioStore = create((set, get) => ({
keepLogs: readKeepLogs(),
cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(),
completionSound: readCompletionSoundConfig(),
+ completionNotification: readCompletionNotificationConfig(),
+ completionNotificationPermission: readSystemNotificationPermission(),
ignoredReleaseTag: readIgnoredReleaseTag(),
appUpdate: null,
appUpdateModalOpen: false,
@@ -790,6 +800,30 @@ export const useStudioStore = create((set, get) => ({
previewCompletionSound: async () => {
await playCompletionSound(get().completionSound, { force: true });
},
+ setCompletionNotificationEnabled: async (value) => {
+ if (!value) {
+ const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: false });
+ persistCompletionNotificationConfig(next);
+ const permission = readSystemNotificationPermission();
+ set({
+ completionNotification: next,
+ completionNotificationPermission: permission,
+ });
+ return permission;
+ }
+ const permission = await requestSystemNotificationPermission();
+ set({ completionNotificationPermission: permission });
+ if (permission !== "granted") return permission;
+ const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: true });
+ persistCompletionNotificationConfig(next);
+ set({ completionNotification: next });
+ return permission;
+ },
+ requestCompletionNotificationPermission: async () => {
+ const permission = await requestSystemNotificationPermission();
+ set({ completionNotificationPermission: permission });
+ return permission;
+ },
workspaces: [],
activeWorkspaceId: "",
styleTag: "",
@@ -1406,6 +1440,8 @@ export const useStudioStore = create((set, get) => ({
keepLogs: readKeepLogs(),
cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(),
completionSound: readCompletionSoundConfig(),
+ completionNotification: readCompletionNotificationConfig(),
+ completionNotificationPermission: readSystemNotificationPermission(),
ignoredReleaseTag: readIgnoredReleaseTag(),
appUpdate: null,
appUpdateModalOpen: false,
@@ -1452,6 +1488,8 @@ export const useStudioStore = create((set, get) => ({
const keepLogs = readKeepLogs();
const cleanupPreviewCacheOnExit = readCleanupPreviewCacheOnExit();
const completionSound = readCompletionSoundConfig();
+ const completionNotification = readCompletionNotificationConfig();
+ const completionNotificationPermission = readSystemNotificationPermission();
const ignoredReleaseTag = readIgnoredReleaseTag();
const updateInfo = normalizeAppUpdateInfo(await CheckForAppUpdate().catch(() => null));
const shouldShowUpdate = !!updateInfo?.hasUpdate && updateInfo.releaseTag !== ignoredReleaseTag;
@@ -1697,6 +1735,8 @@ export const useStudioStore = create((set, get) => ({
keepLogs,
cleanupPreviewCacheOnExit,
completionSound,
+ completionNotification,
+ completionNotificationPermission,
ignoredReleaseTag,
appUpdate: shouldShowUpdate ? updateInfo : null,
appUpdateModalOpen: shouldShowUpdate,
@@ -2253,12 +2293,19 @@ async function launchOneJob(
completedNow,
totalNow,
});
+ const shouldNotifyCompletion = shouldSendCompletionNotification({
+ config: store.getState().completionNotification,
+ completedNow,
+ totalNow,
+ windowHidden: willNotify,
+ });
if (shouldPlaySound) {
void playCompletionSound(store.getState().completionSound);
}
- // 桌面通知 —— 点击拉前台 + 直达详情抽屉
- if (willNotify && (!loopMode || isFinalLoopResult)) {
- tryNotify("Image Studio · 已完成", r.prompt ?? "", () => {
+ if (shouldNotifyCompletion && (!loopMode || isFinalLoopResult)) {
+ const notificationBody = (r.revisedPrompt ?? r.prompt ?? payload.prompt ?? "").trim()
+ || (historyItem.mode === "edit" ? "图片编辑任务已完成" : "图片生成任务已完成");
+ showSystemNotification("Image Studio · 已完成", notificationBody, () => {
store.getState().openResultDetail(historyItem);
});
}
diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts
index 185616cf2..16e30942b 100644
--- a/image-studio/frontend/src/state/studioStore.types.ts
+++ b/image-studio/frontend/src/state/studioStore.types.ts
@@ -17,6 +17,7 @@ import type {
OutputFormatValue,
CustomAspectRatio,
CompletionSoundConfig,
+ CompletionNotificationConfig,
Preset,
ProgressInfo,
PromptTemplate,
@@ -27,6 +28,7 @@ import type {
SourceImage,
StreamPreview,
StreamPreviewMap,
+ SystemNotificationPermissionState,
ThemeMode,
Toast,
UpstreamProfile,
@@ -216,6 +218,8 @@ export interface StudioState {
keepLogs: boolean;
cleanupPreviewCacheOnExit: boolean;
completionSound: CompletionSoundConfig;
+ completionNotification: CompletionNotificationConfig;
+ completionNotificationPermission: SystemNotificationPermissionState;
ignoredReleaseTag: string;
appUpdate: AppUpdateInfo | null;
appUpdateModalOpen: boolean;
@@ -231,6 +235,8 @@ export interface StudioState {
setCompletionSoundCustom: (input: { name: string; dataURL: string }) => void;
resetCompletionSoundCustom: () => void;
previewCompletionSound: () => Promise;
+ setCompletionNotificationEnabled: (value: boolean) => Promise;
+ requestCompletionNotificationPermission: () => Promise;
materializeCurrentImage: (item: HistoryItem) => Promise;
retryLast: () => Promise;
setHistoryRailCollapsed: (collapsed: boolean) => void;
diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts
index deeca8cf5..c5c6c68bf 100644
--- a/image-studio/frontend/src/types/domain.ts
+++ b/image-studio/frontend/src/types/domain.ts
@@ -57,6 +57,7 @@ export type ThemeMode = "system" | "light" | "dark";
export type CompletionSoundMode = "default" | "custom";
export type EditSourceMode = "manual" | "batch";
export type BatchProcessOutputMode = "source_dir" | "custom_dir";
+export type SystemNotificationPermissionState = "default" | "granted" | "denied" | "unsupported";
export interface CompletionSoundConfig {
enabled: boolean;
@@ -65,6 +66,10 @@ export interface CompletionSoundConfig {
customDataURL: string;
}
+export interface CompletionNotificationConfig {
+ enabled: boolean;
+}
+
export interface SizeOption { value: SizeValue; label: string; }
export interface QualityOption { value: QualityValue; label: string; }
export interface OutputFormatOption { value: OutputFormatValue; label: string; }
diff --git a/image-studio/frontend/test/completionNotification.test.mjs b/image-studio/frontend/test/completionNotification.test.mjs
new file mode 100644
index 000000000..712f8c164
--- /dev/null
+++ b/image-studio/frontend/test/completionNotification.test.mjs
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+const realLocalStorage = globalThis.localStorage;
+const realNotification = globalThis.Notification;
+const realWindow = globalThis.window;
+
+function installStorage() {
+ const store = new Map();
+ globalThis.localStorage = {
+ getItem(key) {
+ return store.has(key) ? store.get(key) : null;
+ },
+ setItem(key, value) {
+ store.set(key, String(value));
+ },
+ removeItem(key) {
+ store.delete(key);
+ },
+ };
+ return store;
+}
+
+test.afterEach(() => {
+ globalThis.localStorage = realLocalStorage;
+ globalThis.Notification = realNotification;
+ globalThis.window = realWindow;
+});
+
+test("completion notification persists enabled state", async () => {
+ const store = installStorage();
+ const completionNotification = await import("../src/lib/completionNotification.ts");
+
+ completionNotification.persistCompletionNotificationConfig({ enabled: false });
+ assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: false });
+ assert.equal(store.get("gptcodex.completionNotification.enabled"), "0");
+
+ completionNotification.persistCompletionNotificationConfig({ enabled: true });
+ assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: true });
+ assert.equal(store.get("gptcodex.completionNotification.enabled"), "1");
+});
+
+test("completion notification only fires for the final hidden task", async () => {
+ const completionNotification = await import("../src/lib/completionNotification.ts");
+
+ assert.equal(completionNotification.shouldSendCompletionNotification({
+ config: { enabled: true },
+ completedNow: 1,
+ totalNow: 3,
+ windowHidden: true,
+ }), false);
+
+ assert.equal(completionNotification.shouldSendCompletionNotification({
+ config: { enabled: true },
+ completedNow: 3,
+ totalNow: 3,
+ windowHidden: false,
+ }), false);
+
+ assert.equal(completionNotification.shouldSendCompletionNotification({
+ config: { enabled: true },
+ completedNow: 3,
+ totalNow: 3,
+ windowHidden: true,
+ }), true);
+});
+
+test("system notification only shows after permission is granted", async () => {
+ const seen = [];
+ globalThis.window = { focus() {} };
+ globalThis.Notification = class MockNotification {
+ static permission = "granted";
+ static async requestPermission() {
+ return this.permission;
+ }
+ constructor(title, options) {
+ seen.push({ title, body: options.body });
+ this.onclick = null;
+ }
+ close() {}
+ };
+
+ const completionNotification = await import("../src/lib/completionNotification.ts");
+ assert.equal(completionNotification.showSystemNotification("done", "body"), true);
+ assert.deepEqual(seen, [{ title: "done", body: "body" }]);
+
+ globalThis.Notification.permission = "denied";
+ assert.equal(completionNotification.showSystemNotification("blocked", "body"), false);
+ assert.equal(seen.length, 1);
+});
From 65784c38fff6af7baa4eb9c77d18bebfefc6b654 Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 22:28:29 +0800
Subject: [PATCH 03/11] Improve batch save prompt for concurrent generations
---
.../frontend/src/app/dev/previewData.ts | 1 +
.../frontend/src/app/gates/SavePromptGate.tsx | 4 +-
.../src/components/canvas/BatchResultGrid.tsx | 22 +-
.../src/components/common/SavePromptModal.tsx | 226 +++++++++++++++---
.../frontend/src/lib/savePromptState.ts | 13 +
.../frontend/src/lib/saveResultImage.ts | 8 +
.../frontend/src/state/studioStore.ts | 73 +++++-
.../frontend/src/state/studioStore.types.ts | 7 +-
image-studio/frontend/src/styles/_canvas.css | 16 +-
.../frontend/test/savePromptState.test.mjs | 49 ++++
10 files changed, 364 insertions(+), 55 deletions(-)
create mode 100644 image-studio/frontend/src/lib/savePromptState.ts
create mode 100644 image-studio/frontend/test/savePromptState.test.mjs
diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts
index 27600aa03..e56f09f91 100644
--- a/image-studio/frontend/src/app/dev/previewData.ts
+++ b/image-studio/frontend/src/app/dev/previewData.ts
@@ -28,6 +28,7 @@ export function readPreviewScenario(): PreviewScenario | null {
}
}
+
function buildHistory(now: number): HistoryItem[] {
const batchPrompt = "赛博雨夜角色海报,湿地街道反光,红青霓虹边缘光,35mm,电影感,超细节";
const batchDefs = Array.from({ length: 9 }, (_, index) => ({
diff --git a/image-studio/frontend/src/app/gates/SavePromptGate.tsx b/image-studio/frontend/src/app/gates/SavePromptGate.tsx
index c267cab74..7212394ba 100644
--- a/image-studio/frontend/src/app/gates/SavePromptGate.tsx
+++ b/image-studio/frontend/src/app/gates/SavePromptGate.tsx
@@ -4,8 +4,8 @@ import { useStudioStore } from "../../state/studioStore";
const SavePromptModal = lazy(() => import("../../components/common/SavePromptModal").then((module) => ({ default: module.SavePromptModal })));
export function SavePromptGate() {
- const item = useStudioStore((s) => s.savePromptItem);
- if (!item) return null;
+ const request = useStudioStore((s) => s.savePromptRequest);
+ if (!request) return null;
return (
diff --git a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx
index b626b97f7..104c86c0d 100644
--- a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx
+++ b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx
@@ -15,6 +15,9 @@ export function BatchResultGrid({
onClose,
showClose = true,
title,
+ selectedIds,
+ onToggleSelect,
+ selectionMode = false,
}: {
items: HistoryItem[];
slots?: BatchGridSlot[];
@@ -23,6 +26,9 @@ export function BatchResultGrid({
onClose: () => void;
showClose?: boolean;
title?: string;
+ selectedIds?: Set;
+ onToggleSelect?: (item: HistoryItem) => void;
+ selectionMode?: boolean;
}) {
const gridSlots = slots ?? items.map((item) => ({ type: "result", item }) satisfies BatchGridSlot);
const columns = gridSlots.length <= 2 ? 2 : gridSlots.length <= 4 ? 2 : 3;
@@ -52,6 +58,9 @@ export function BatchResultGrid({
active={slot.type === "result" && slot.item.id === currentId}
preview={slot.type === "preview"}
onSelect={onSelect}
+ selected={slot.type === "result" && !!selectedIds?.has(slot.item.id)}
+ onToggleSelect={onToggleSelect}
+ selectionMode={selectionMode}
/>
);
})}
@@ -66,24 +75,34 @@ function BatchGridTile({
active,
preview,
onSelect,
+ selected,
+ onToggleSelect,
+ selectionMode,
}: {
item: HistoryItem;
index: number;
active: boolean;
preview: boolean;
onSelect: (item: HistoryItem) => void | Promise;
+ selected: boolean;
+ onToggleSelect?: (item: HistoryItem) => void;
+ selectionMode: boolean;
}) {
const previewURL = useBlobURL(item.imageBlob ?? item.previewBlob ?? null, item.imageB64 ?? null);
const src = historyPreviewSrc(item, previewURL);
return (
diff --git a/image-studio/frontend/src/components/common/SavePromptModal.tsx b/image-studio/frontend/src/components/common/SavePromptModal.tsx
index d088b8752..8cc6cce74 100644
--- a/image-studio/frontend/src/components/common/SavePromptModal.tsx
+++ b/image-studio/frontend/src/components/common/SavePromptModal.tsx
@@ -1,30 +1,49 @@
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { FolderOpen, Save } from "lucide-react";
import { useStudioStore } from "../../state/studioStore";
+import { BatchResultGrid } from "../canvas/BatchResultGrid";
import { historyPreviewSrc, useBlobURL } from "../../lib/images";
-import { saveHistoryItemAs } from "../../lib/saveResultImage";
+import { saveHistoryItemAs, saveHistoryItemsToDirectory } from "../../lib/saveResultImage";
import { androidSaveHint, androidTarget } from "../../platform/android/bridge";
+import { getHostCapabilities, ChooseDirectory } from "../../platform/runtime/host";
import { usePlatform } from "../../platform/context";
import { Modal } from "./Modal";
export function SavePromptModal() {
- const item = useStudioStore((s) => s.savePromptItem);
+ const request = useStudioStore((s) => s.savePromptRequest);
const closeSavePrompt = useStudioStore((s) => s.closeSavePrompt);
const suppressed = useStudioStore((s) => s.savePromptSuppressed);
const setSuppressed = useStudioStore((s) => s.setSavePromptSuppressed);
const pushToast = useStudioStore((s) => s.pushToast);
- const { usesFluentUI, isAndroidPhone } = usePlatform();
+ const { usesFluentUI, isAndroidPhone, isAndroid } = usePlatform();
const [saving, setSaving] = useState(false);
- const previewURL = useBlobURL(item?.previewBlob ?? item?.imageBlob ?? null, item?.imageB64 ?? null);
+ const [selectedIds, setSelectedIds] = useState
>(new Set());
- if (!item) return null;
- const imageSrc = historyPreviewSrc(item, previewURL);
+ useEffect(() => {
+ if (request?.kind === "batch") {
+ setSelectedIds(new Set(request.items.map((item) => item.id)));
+ return;
+ }
+ setSelectedIds(new Set());
+ }, [request]);
+
+ const batchItems = request?.kind === "batch" ? request.items : [];
+ const selectedBatchItems = useMemo(
+ () => batchItems.filter((item) => selectedIds.has(item.id)),
+ [batchItems, selectedIds],
+ );
+
+ if (!request) return null;
+ const isBatch = request.kind === "batch";
+ const singleItem = request.kind === "single" ? request.item : null;
+ const batchRequest = request.kind === "batch" ? request : null;
+ const singlePreviewURL = useBlobURL(singleItem?.previewBlob ?? singleItem?.imageBlob ?? null, singleItem?.imageB64 ?? null);
- async function saveAs() {
- if (saving || !item) return;
+ async function saveSingleAs() {
+ if (saving || !singleItem) return;
setSaving(true);
try {
- const saved = await saveHistoryItemAs(item);
+ const saved = await saveHistoryItemAs(singleItem);
if (saved) pushToast(`已保存:${saved.split(/[\\/]/).pop()}`, "success");
closeSavePrompt();
} catch (error: any) {
@@ -34,39 +53,172 @@ export function SavePromptModal() {
}
}
- return (
-
-
-
-
- {imageSrc ? (
-

- ) : (
-
- )}
+ async function saveBatchAs() {
+ if (saving || !batchRequest) return;
+ if (selectedBatchItems.length === 0) {
+ pushToast("请先勾选要另存的图片", "warn");
+ return;
+ }
+ if (!(hostCapabilities.nativeOutputDirectoryPicker && !isAndroid)) {
+ pushToast("当前平台暂不支持批量选择目录另存,请改用单图另存为。", "warn", 5000);
+ return;
+ }
+ setSaving(true);
+ try {
+ const directory = await ChooseDirectory("选择批量另存为目录");
+ if (!directory) {
+ setSaving(false);
+ return;
+ }
+ const saved = await saveHistoryItemsToDirectory(selectedBatchItems, directory);
+ pushToast(`已另存 ${saved.length} 张到 ${directory.split(/[\\/]/).pop() || directory}`, "success", 6000);
+ closeSavePrompt();
+ } catch (error: any) {
+ pushToast(`批量保存失败:${error?.message ?? error}`, "error", 6000);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (!isBatch) {
+ const imageSrc = historyPreviewSrc(singleItem, singlePreviewURL);
+ return (
+
+
+
+
+ {imageSrc ? (
+

+ ) : (
+
+ )}
+
+
+
+ 图片已生成并保存在默认输出目录。
+
+
+ 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。
+
+ {singleItem?.savedPath ? (
+
+ {singleItem.savedPath}
+
+ ) : null}
+
+
+
+ {androidTarget.isAndroid ? (
+
{androidSaveHint()}
+ ) : null}
+
+
+
+
+
+
-
+
+
+ );
+ }
+
+ const hostCapabilities = getHostCapabilities();
+ const canBatchSaveToDirectory = hostCapabilities.nativeOutputDirectoryPicker && !isAndroid;
+
+ return (
+
+
+
+
- 图片已生成并保存在默认输出目录。
+ 单次任务只弹这一次。勾选需要的图片后,再统一另存为到目标目录。
- 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。
+ 当前已选 {selectedBatchItems.length} / {batchItems.length} 张。
- {item.savedPath ? (
-
- {item.savedPath}
-
- ) : null}
+
+
+
+
+
+
+
+ undefined}
+ onClose={closeSavePrompt}
+ showClose={false}
+ title={`勾选要另存的结果 · ${batchItems.length} 张`}
+ selectionMode
+ selectedIds={selectedIds}
+ onToggleSelect={(item) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(item.id)) next.delete(item.id);
+ else next.add(item.id);
+ return next;
+ });
+ }}
+ />
{androidTarget.isAndroid ? (
-
{androidSaveHint()}
+
+ {androidSaveHint()} 当前平台批量勾选后统一目录另存尚不可用,请改用单图另存为。
+
+ ) : !canBatchSaveToDirectory ? (
+
+ 当前环境没有可用的目录选择器,暂时无法一次性批量另存。
+
) : null}
diff --git a/image-studio/frontend/src/lib/savePromptState.ts b/image-studio/frontend/src/lib/savePromptState.ts
new file mode 100644
index 000000000..ed5cde231
--- /dev/null
+++ b/image-studio/frontend/src/lib/savePromptState.ts
@@ -0,0 +1,13 @@
+import type { HistoryItem } from "../types/domain";
+
+export type SavePromptRequest =
+ | { kind: "single"; item: HistoryItem }
+ | { kind: "batch"; items: HistoryItem[]; workspaceId: string };
+
+export function normalizeSavePromptRequest(request: SavePromptRequest): SavePromptRequest | null {
+ if (request.kind === "single") {
+ return request.item ? request : null;
+ }
+ if (request.items.length === 0) return null;
+ return request;
+}
diff --git a/image-studio/frontend/src/lib/saveResultImage.ts b/image-studio/frontend/src/lib/saveResultImage.ts
index c976cafaa..59288e34f 100644
--- a/image-studio/frontend/src/lib/saveResultImage.ts
+++ b/image-studio/frontend/src/lib/saveResultImage.ts
@@ -22,3 +22,11 @@ export async function saveHistoryItemToDirectoryAs(item: HistoryItem, directory:
if (item.imageB64) return SaveImageToDir(item.imageB64, directory, suggestedName);
throw new Error("当前图片没有可保存内容");
}
+
+export async function saveHistoryItemsToDirectory(items: HistoryItem[], directory: string): Promise {
+ const saved: string[] = [];
+ for (const item of items) {
+ saved.push(await saveHistoryItemToDirectory(item, directory));
+ }
+ return saved;
+}
diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts
index cf599853d..d0413221f 100644
--- a/image-studio/frontend/src/state/studioStore.ts
+++ b/image-studio/frontend/src/state/studioStore.ts
@@ -69,6 +69,7 @@ import {
} from "../lib/compatState";
import { normalizeAppUpdateInfo } from "../lib/appUpdate.ts";
import { appVersion } from "../lib/version.ts";
+import { normalizeSavePromptRequest, type SavePromptRequest } from "../lib/savePromptState";
import {
readSavePromptSuppressed,
writeSavePromptSuppressed,
@@ -167,6 +168,7 @@ import type { ModeConfig, PromptOptimizeRequest, Stroke, StudioState, UndoEntry
import {
cryptoIDFallback,
ensureFullHistoryItem as ensureFullHistoryItemRuntime,
+ historyItemsByIds,
materializeHistoryItem as materializeHistoryItemRuntime,
STYLE_SUFFIXES,
withMediaAssetRef,
@@ -448,6 +450,36 @@ function mergeHistoryMediaRef(current: HistoryItem | null, nextById: Map entry.id === workspaceId);
+ const items = historyItemsByIds(state.history, workspace?.batchResultIds ?? []).sort((a, b) => {
+ const aIndex = typeof a.batchIndex === "number" ? a.batchIndex : Number.MAX_SAFE_INTEGER;
+ const bIndex = typeof b.batchIndex === "number" ? b.batchIndex : Number.MAX_SAFE_INTEGER;
+ if (aIndex !== bIndex) return aIndex - bIndex;
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
+ return a.id.localeCompare(b.id);
+ });
+ if (items.length === 0) return null;
+ return {
+ kind: "batch",
+ items,
+ workspaceId,
+ };
+}
+
+function maybeEnqueueBatchSavePrompt(
+ store: typeof useStudioStore,
+ workspaceId: string,
+ completedNow: number,
+ totalNow: number,
+): void {
+ if (totalNow <= 1 || completedNow !== totalNow) return;
+ const batchRequest = batchSavePromptRequestFromState(store.getState(), workspaceId);
+ if (batchRequest) {
+ store.getState().enqueueSavePrompt(batchRequest);
+ }
+}
+
async function hydrateHistoryPreviewRefs(items: HistoryItem[]): Promise {
return mapWithConcurrency(items, HISTORY_MEDIA_HYDRATE_CONCURRENCY, async (item) => {
if (!needsHistoryPreviewHydration(item)) return item;
@@ -603,7 +635,7 @@ export const useStudioStore = create((set, get) => ({
fullscreen: false,
starPromptOpen: false,
starPromptSource: "auto",
- savePromptItem: null,
+ savePromptRequest: null,
savePromptQueue: [],
savePromptSuppressed: readSavePromptSuppressed(),
keepLogs: readKeepLogs(),
@@ -725,25 +757,34 @@ export const useStudioStore = create((set, get) => ({
set({ starPromptOpen: false });
try { localStorage.setItem("gptcodex.starPrompted", "1"); } catch {}
},
- enqueueSavePrompt: (item) => {
+ enqueueSavePrompt: (request) => {
if (get().savePromptSuppressed) return;
+ const normalized = normalizeSavePromptRequest(request);
+ if (!normalized) return;
+ const hydrated = normalized.kind === "batch"
+ ? {
+ ...normalized,
+ items: historyItemsByIds(normalized.items, normalized.items.map((item) => item.id)),
+ }
+ : normalized;
+ if (hydrated.kind === "batch" && hydrated.items.length === 0) return;
set((state) => {
- if (!state.savePromptItem) return { savePromptItem: item };
- return { savePromptQueue: [...state.savePromptQueue, item].slice(-12) };
+ if (!state.savePromptRequest) return { savePromptRequest: hydrated };
+ return { savePromptQueue: [...state.savePromptQueue, hydrated].slice(-12) };
});
},
closeSavePrompt: () => {
set((state) => {
const [next, ...rest] = state.savePromptQueue;
return {
- savePromptItem: next ?? null,
+ savePromptRequest: next ?? null,
savePromptQueue: rest,
};
});
},
setSavePromptSuppressed: (value) => {
writeSavePromptSuppressed(value);
- set(value ? { savePromptSuppressed: true, savePromptQueue: [] } : { savePromptSuppressed: false });
+ set(value ? { savePromptSuppressed: true, savePromptRequest: null, savePromptQueue: [] } : { savePromptSuppressed: false });
},
setKeepLogs: async (value) => {
writeKeepLogs(value);
@@ -1434,7 +1475,7 @@ export const useStudioStore = create((set, get) => ({
starPromptOpen: false,
starPromptSource: "auto",
autoRetryEnabled: readAutoRetryEnabled(),
- savePromptItem: null,
+ savePromptRequest: null,
savePromptQueue: [],
savePromptSuppressed: readSavePromptSuppressed(),
keepLogs: readKeepLogs(),
@@ -1729,7 +1770,7 @@ export const useStudioStore = create((set, get) => ({
customSizeModalOpen: false,
upstreamModalOpen: false,
upstreamReturnTarget: shouldAutoOpenSettings ? "settings" : "app",
- savePromptItem: null,
+ savePromptRequest: null,
savePromptQueue: [],
savePromptSuppressed: readSavePromptSuppressed(),
keepLogs,
@@ -2318,7 +2359,10 @@ async function launchOneJob(
6000,
{ label: "查看详情", onClick: () => store.getState().openResultDetail(historyItem) },
);
- store.getState().enqueueSavePrompt(historyItem);
+ if (totalNow <= 1) {
+ store.getState().enqueueSavePrompt({ kind: "single", item: historyItem });
+ }
+ maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow);
} else if (isFinalLoopResult) {
store.getState().pushToast(
`循环出图完成 · ${completedNow} 张`,
@@ -2376,7 +2420,8 @@ async function launchOneJob(
workspaces: patchWorkspaceRuntime(state.workspaces, snapshot.workspaceId, patch),
...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(patch) : {}),
} as Partial));
- removeFromRunning();
+ const { completed: completedNow, total: totalNow } = removeFromRunning();
+ maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow);
settle("error");
}
})();
@@ -2411,7 +2456,8 @@ async function launchOneJob(
: {}),
} as Partial;
});
- removeFromRunning();
+ const { completed: completedNow, total: totalNow } = removeFromRunning();
+ maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow);
settle("error");
});
const started = mode === "edit"
@@ -2428,8 +2474,12 @@ async function launchOneJob(
errorCanRetry: true,
errorRawPath: null,
};
+ let completedNow = 0;
+ let totalNow = 0;
store.setState((state) => {
const runtime = workspaceRuntimeFromState(state, snapshot.workspaceId);
+ completedNow = runtime.jobsCompleted + 1;
+ totalNow = runtime.jobsTotal;
const nextMeta = { ...state.runningJobMeta };
delete nextMeta[jobId];
const remaining = runtime.runningJobs.filter((id) => id !== jobId);
@@ -2450,6 +2500,7 @@ async function launchOneJob(
...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(nextPatch) : {}),
} as Partial;
});
+ maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow);
settle("error");
}
}
diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts
index 16e30942b..4ef3081e2 100644
--- a/image-studio/frontend/src/state/studioStore.types.ts
+++ b/image-studio/frontend/src/state/studioStore.types.ts
@@ -1,4 +1,5 @@
import type { GenerateOptionsLike } from "../platform/runtime/hostTypes";
+import type { SavePromptRequest } from "../lib/savePromptState";
import type {
Annotation,
AppUpdateInfo,
@@ -212,8 +213,8 @@ export interface StudioState {
resultDetail: HistoryItem | null;
openResultDetail: (item: HistoryItem) => Promise;
closeResultDetail: () => void;
- savePromptItem: HistoryItem | null;
- savePromptQueue: HistoryItem[];
+ savePromptRequest: SavePromptRequest | null;
+ savePromptQueue: SavePromptRequest[];
savePromptSuppressed: boolean;
keepLogs: boolean;
cleanupPreviewCacheOnExit: boolean;
@@ -223,7 +224,7 @@ export interface StudioState {
ignoredReleaseTag: string;
appUpdate: AppUpdateInfo | null;
appUpdateModalOpen: boolean;
- enqueueSavePrompt: (item: HistoryItem) => void;
+ enqueueSavePrompt: (request: SavePromptRequest) => void;
closeSavePrompt: () => void;
setSavePromptSuppressed: (value: boolean) => void;
setKeepLogs: (value: boolean) => Promise;
diff --git a/image-studio/frontend/src/styles/_canvas.css b/image-studio/frontend/src/styles/_canvas.css
index d13004ed7..a3e6b5de8 100644
--- a/image-studio/frontend/src/styles/_canvas.css
+++ b/image-studio/frontend/src/styles/_canvas.css
@@ -512,9 +512,16 @@ html.dark .stream-preview-badge {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 12px 34px rgb(0 0 0 / 0.16);
}
+.batch-grid-tile.selected {
+ border-color: color-mix(in srgb, var(--accent) 70%, transparent);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent), 0 12px 34px rgb(0 0 0 / 0.16);
+}
.batch-grid-tile:disabled {
cursor: default;
}
+.batch-grid-tile.selection-mode .batch-grid-drag-export {
+ display: none;
+}
.batch-grid-tile.previewing {
cursor: default;
border-color: color-mix(in srgb, var(--accent) 36%, var(--border));
@@ -541,7 +548,8 @@ html.dark .stream-preview-badge {
background: var(--canvas-bg);
}
.batch-grid-index,
-.batch-grid-meta {
+.batch-grid-meta,
+.batch-grid-check {
position: absolute;
top: 8px;
background: rgb(0 0 0 / 0.62);
@@ -554,6 +562,12 @@ html.dark .stream-preview-badge {
}
.batch-grid-index { left: 8px; }
.batch-grid-meta { right: 8px; font-family: var(--mono-font); }
+.batch-grid-check {
+ top: auto;
+ right: 8px;
+ bottom: 8px;
+ background: color-mix(in srgb, var(--accent) 82%, black 18%);
+}
.batch-grid-drag-export {
right: 8px;
bottom: 8px;
diff --git a/image-studio/frontend/test/savePromptState.test.mjs b/image-studio/frontend/test/savePromptState.test.mjs
new file mode 100644
index 000000000..205064f4b
--- /dev/null
+++ b/image-studio/frontend/test/savePromptState.test.mjs
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+const savePromptState = await import("../src/lib/savePromptState.ts");
+
+function makeItem(id, createdAt) {
+ return {
+ id,
+ prompt: `prompt-${id}`,
+ mode: "generate",
+ size: "1024x1024",
+ quality: "medium",
+ createdAt,
+ };
+}
+
+test("normalizeSavePromptRequestForTest keeps batch items in request order", () => {
+ const one = makeItem("one", 1);
+ const two = makeItem("two", 2);
+ const request = savePromptState.normalizeSavePromptRequest({
+ kind: "batch",
+ items: [two, one],
+ workspaceId: "ws-1",
+ });
+
+ assert.equal(request?.kind, "batch");
+ assert.deepEqual(request?.items.map((item) => item.id), ["two", "one"]);
+});
+
+test("normalizeSavePromptRequest keeps single requests intact", () => {
+ const one = makeItem("one", 1);
+ const request = savePromptState.normalizeSavePromptRequest({
+ kind: "single",
+ item: one,
+ });
+
+ assert.equal(request?.kind, "single");
+ assert.equal(request?.item.id, "one");
+});
+
+test("normalizeSavePromptRequest rejects empty batch requests", () => {
+ const request = savePromptState.normalizeSavePromptRequest({
+ kind: "batch",
+ items: [],
+ workspaceId: "ws-1",
+ });
+
+ assert.equal(request, null);
+});
From 4552f298e66cb76783d22cb1f244cd91f6ca87bf Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 22:45:46 +0800
Subject: [PATCH 04/11] =?UTF-8?q?=E6=89=B9=E5=A4=84=E7=90=86=E4=BC=98?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/batch-img2img/README.md | 35 +++++++++++++++
image-studio/backend/dialogs.go | 4 ++
image-studio/backend/types.go | 2 +
.../frontend/src/app/dev/previewData.ts | 1 +
.../components/panel/BatchProcessSection.tsx | 45 +++++++++++++++++++
.../src/components/panel/sizeCapabilities.ts | 26 +++++++++++
image-studio/frontend/src/lib/compatState.ts | 3 ++
.../src/platform/runtime/hostTypes.ts | 2 +
.../frontend/src/state/studioStore.images.ts | 4 ++
.../frontend/src/state/studioStore.ts | 27 +++++++++++
.../frontend/src/state/workspaceRuntime.ts | 13 ++++++
image-studio/frontend/src/types/domain.ts | 4 ++
.../frontend/test/sizeCapabilities.test.mjs | 16 +++++++
.../frontend/test/workspaceRuntime.test.mjs | 26 +++++++++++
14 files changed, 208 insertions(+)
diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md
index b7d49dbce..8f5a58e7a 100644
--- a/docs/batch-img2img/README.md
+++ b/docs/batch-img2img/README.md
@@ -89,6 +89,41 @@
- 实际可并发数量仍受当前上游 profile 的并发限制约束。
- 如果并发开得太高,应用可能会自动关闭流式预览,优先保证最终图完整。
+## 不同比例源图的处理
+
+如果一个目录里的源图宽高比不一致,但你仍然希望:
+
+- 所有图片使用相同提示词
+- 所有图片使用同一档分辨率
+- 每张图仍然尽量保持各自源图的比例
+
+可以在批处理面板里开启:
+
+`按源图比例自动适配`
+
+然后再选择统一的分辨率档位,例如 `1K`、`2K`、`4K`。
+
+这时应用会对每一张源图分别计算:
+
+- 比例:取该源图自身宽高比
+- 分辨率:取你在批处理面板里指定的统一档位,并沿用应用当前对 `1K / 2K / 4K` 的尺寸换算规则
+
+例如:
+
+- 横图会得到横向尺寸
+- 竖图会得到纵向尺寸
+- 它们都仍然共享同一个提示词和同一个分辨率档位
+
+这里的 `1K / 2K / 4K` 仍然遵循应用当前的尺寸语义:
+
+- 内置比例会落到内置尺寸矩阵
+- 非内置比例会按该档位的像素面积参考值推导具体宽高
+
+注意:
+
+- 这是批处理模式下的额外尺寸策略。
+- 它不会改变普通图生图里 `Auto` 比例与 `size=auto` 的原有语义。
+
## 参数继承
批处理会复用你当前图生图控制面板里的参数,包括:
diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go
index a49c926a7..6f31bff70 100644
--- a/image-studio/backend/dialogs.go
+++ b/image-studio/backend/dialogs.go
@@ -121,6 +121,10 @@ func (s *Service) ListBatchInputImages(directory string) (BatchInputDirectory, e
Name: entry.Name(),
Size: info.Size(),
}
+ if cfg, cfgErr := imageConfig(path); cfgErr == nil {
+ item.Width = cfg.Width
+ item.Height = cfg.Height
+ }
if info.Size() > 0 && info.Size() <= maxDialogReadBytes {
if preview, previewErr := s.registerImportedPreview(path); previewErr == nil {
item.PreviewURL = preview.PreviewURL
diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go
index 9c25c7f57..4abec4c67 100644
--- a/image-studio/backend/types.go
+++ b/image-studio/backend/types.go
@@ -203,6 +203,8 @@ type BatchInputImage struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
PreviewWidth int `json:"previewWidth,omitempty"`
PreviewHeight int `json:"previewHeight,omitempty"`
diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts
index e56f09f91..695f94e83 100644
--- a/image-studio/frontend/src/app/dev/previewData.ts
+++ b/image-studio/frontend/src/app/dev/previewData.ts
@@ -184,6 +184,7 @@ function buildWorkspace(
outputDir: "",
concurrency: 2,
fileNamePrefix: "processed-",
+ autoAspectResolution: "",
discoveredSources: [],
};
return {
diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
index 42a24824b..8617fdff4 100644
--- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
+++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
@@ -140,6 +140,51 @@ export function BatchProcessSection({
/>
+
+
不同源图比例处理
+
+ setBatchProcess({ ...batchProcess, autoAspectResolution: "" })}
+ >
+ 沿用当前比例
+
+ setBatchProcess({ ...batchProcess, autoAspectResolution: batchProcess.autoAspectResolution || "1k" })}
+ >
+ 按源图比例自动适配
+
+
+ {batchProcess.autoAspectResolution !== "" ? (
+ <>
+
+ {(["256", "512", "1k", "2k", "4k"] as const).map((value) => (
+
+ ))}
+
+
+ 开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。
+
+ >
+ ) : (
+
+ 关闭时仍沿用当前控制面板的比例和尺寸语义。
+
+ )}
+
+
结果文件名前缀固定为 processed-,遇到同名会自动追加 -2、-3。
diff --git a/image-studio/frontend/src/components/panel/sizeCapabilities.ts b/image-studio/frontend/src/components/panel/sizeCapabilities.ts
index dcd82f699..7677582ad 100644
--- a/image-studio/frontend/src/components/panel/sizeCapabilities.ts
+++ b/image-studio/frontend/src/components/panel/sizeCapabilities.ts
@@ -408,6 +408,32 @@ export function buildResolutionSizeSelection(
);
}
+export function buildReferenceResolutionSizeSelection(
+ resolution: Exclude,
+ reference: { width: number; height: number } | null,
+ input: SizeCapabilityInput,
+ customRatios: CustomAspectRatio[] = [],
+): SizeValue {
+ if (!reference || !Number.isFinite(reference.width) || !Number.isFinite(reference.height) || reference.width <= 0 || reference.height <= 0) {
+ return buildResolutionSizeSelection("auto", resolution, input, customRatios);
+ }
+ if (supportsCustomAspectRatios(input)) {
+ const customReference = buildReferenceAspectRatio(reference.width, reference.height, customRatios);
+ if (customReference) {
+ const ratios = customRatios.some((item) => item.id === customReference.id)
+ ? customRatios
+ : [...customRatios, customReference];
+ return buildSizeSelection(buildCustomAspectValue(customReference.id), resolution, input, ratios);
+ }
+ }
+ return buildSizeSelection(
+ nearestBuiltinAspect(reference.width, reference.height).value,
+ resolution,
+ input,
+ customRatios,
+ );
+}
+
export function normalizeResolutionSelection(resolution: ResolutionPreset, input: SizeCapabilityInput): ResolutionPreset {
const allowed = availableResolutionPresets(input);
if (allowed.includes(resolution)) return resolution;
diff --git a/image-studio/frontend/src/lib/compatState.ts b/image-studio/frontend/src/lib/compatState.ts
index 4f45ff4b8..0c53e9ab9 100644
--- a/image-studio/frontend/src/lib/compatState.ts
+++ b/image-studio/frontend/src/lib/compatState.ts
@@ -575,6 +575,9 @@ export function normalizeBatchProcess(value: unknown): BatchProcessConfig {
outputDir: typeof source.outputDir === "string" ? source.outputDir.trim() : "",
concurrency: Number.isFinite(Number(source.concurrency)) ? Math.max(1, Math.min(9, Math.floor(Number(source.concurrency)))) : 2,
fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-",
+ autoAspectResolution: source.autoAspectResolution === "256" || source.autoAspectResolution === "512" || source.autoAspectResolution === "1k" || source.autoAspectResolution === "2k" || source.autoAspectResolution === "4k"
+ ? source.autoAspectResolution
+ : "",
discoveredSources,
};
}
diff --git a/image-studio/frontend/src/platform/runtime/hostTypes.ts b/image-studio/frontend/src/platform/runtime/hostTypes.ts
index 2da022f42..0290631d1 100644
--- a/image-studio/frontend/src/platform/runtime/hostTypes.ts
+++ b/image-studio/frontend/src/platform/runtime/hostTypes.ts
@@ -103,6 +103,8 @@ export type BatchInputImageLike = {
path: string;
name: string;
size: number;
+ width?: number;
+ height?: number;
previewUrl?: string;
previewWidth?: number;
previewHeight?: number;
diff --git a/image-studio/frontend/src/state/studioStore.images.ts b/image-studio/frontend/src/state/studioStore.images.ts
index dd7c2cf89..1897b206f 100644
--- a/image-studio/frontend/src/state/studioStore.images.ts
+++ b/image-studio/frontend/src/state/studioStore.images.ts
@@ -68,6 +68,8 @@ export function createImageActions(store: StateAdapter) {
path: string;
name: string;
size: number;
+ width?: number;
+ height?: number;
previewUrl?: string;
previewWidth?: number;
previewHeight?: number;
@@ -76,6 +78,8 @@ export function createImageActions(store: StateAdapter) {
path: source.path,
name: source.name,
size: source.size,
+ width: source.width,
+ height: source.height,
previewUrl: source.previewUrl,
previewWidth: source.previewWidth,
previewHeight: source.previewHeight,
diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts
index d0413221f..df9570b17 100644
--- a/image-studio/frontend/src/state/studioStore.ts
+++ b/image-studio/frontend/src/state/studioStore.ts
@@ -135,6 +135,7 @@ import { getStreamPreviewDisableReason } from "./streamPreviewPolicy";
import {
buildAspectSizeSelection,
buildExactSizeValue,
+ buildReferenceResolutionSizeSelection,
buildCustomAspectValue,
deriveAspectPreset,
deriveResolutionPreset,
@@ -221,6 +222,13 @@ type LoopRunController = {
batchSources?: BatchProcessSourceImage[];
batchOutputDir?: string;
batchOutputPrefix?: string;
+ batchAutoAspectResolution?: Exclude | "";
+ batchCapabilityInput?: {
+ apiMode: APIMode;
+ requestPolicy: RequestPolicy;
+ imageModelID?: string;
+ };
+ batchCustomAspectRatios?: import("../types/domain").CustomAspectRatio[];
stopped: boolean;
};
@@ -261,9 +269,21 @@ function launchQueuedLoopJobs(controller: LoopRunController): void {
controller.launchedJobs += 1;
const payloadSeed = controller.payload.seed ? controller.payload.seed + batchIndex : 0;
const batchSource = controller.batchSources?.[batchIndex];
+ const batchPayloadSize = batchSource
+ && controller.snapshotBase.editSourceMode === "batch"
+ && controller.batchAutoAspectResolution
+ && controller.batchCapabilityInput
+ ? buildReferenceResolutionSizeSelection(
+ controller.batchAutoAspectResolution,
+ batchSource.width && batchSource.height ? { width: batchSource.width, height: batchSource.height } : null,
+ controller.batchCapabilityInput,
+ controller.batchCustomAspectRatios ?? [],
+ )
+ : controller.payload.size;
const nextPayload: RuntimeGenerateOptions = {
...controller.payload,
seed: payloadSeed,
+ size: batchPayloadSize,
imagePaths: batchSource ? [batchSource.path] : controller.payload.imagePaths,
sourceImages: batchSource ? [batchSource] : controller.payload.sourceImages,
};
@@ -1304,6 +1324,13 @@ export const useStudioStore = create((set, get) => ({
batchSources: batchProcess.discoveredSources,
batchOutputDir: batchProcess.outputMode === "custom_dir" ? batchProcess.outputDir : "",
batchOutputPrefix: batchProcess.fileNamePrefix,
+ batchAutoAspectResolution: batchProcess.autoAspectResolution,
+ batchCapabilityInput: {
+ apiMode: s.apiMode,
+ requestPolicy: s.requestPolicy,
+ imageModelID: s.imageModelID,
+ },
+ batchCustomAspectRatios: s.customAspectRatios,
stopped: false,
};
loopRunControllers.set(workspaceId, controller);
diff --git a/image-studio/frontend/src/state/workspaceRuntime.ts b/image-studio/frontend/src/state/workspaceRuntime.ts
index 6d8027937..2498b7d9c 100644
--- a/image-studio/frontend/src/state/workspaceRuntime.ts
+++ b/image-studio/frontend/src/state/workspaceRuntime.ts
@@ -1,4 +1,5 @@
import type {
+ BatchProcessAutoAspectResolution,
BatchProcessSourceImage,
BatchProcessConfig,
LoopGenerationConfig,
@@ -97,10 +98,17 @@ export function defaultBatchProcessConfig(): BatchProcessConfig {
outputDir: "",
concurrency: DEFAULT_BATCH_PROCESS_CONCURRENCY,
fileNamePrefix: "processed-",
+ autoAspectResolution: "",
discoveredSources: [],
};
}
+export function normalizeBatchProcessAutoAspectResolution(value: unknown): BatchProcessAutoAspectResolution {
+ return value === "256" || value === "512" || value === "1k" || value === "2k" || value === "4k"
+ ? value
+ : "";
+}
+
export function normalizeBatchProcessConcurrency(value: unknown): number {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_BATCH_PROCESS_CONCURRENCY;
@@ -119,6 +127,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig
path?: unknown;
name?: unknown;
size?: unknown;
+ width?: unknown;
+ height?: unknown;
previewUrl?: unknown;
previewWidth?: unknown;
previewHeight?: unknown;
@@ -130,6 +140,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig
path,
name,
size: Number.isFinite(Number(candidate.size)) ? Math.max(0, Math.floor(Number(candidate.size))) : 0,
+ width: Number.isFinite(Number(candidate.width)) ? Math.floor(Number(candidate.width)) : undefined,
+ height: Number.isFinite(Number(candidate.height)) ? Math.floor(Number(candidate.height)) : undefined,
previewUrl: typeof candidate.previewUrl === "string" ? candidate.previewUrl : undefined,
previewWidth: Number.isFinite(Number(candidate.previewWidth)) ? Math.floor(Number(candidate.previewWidth)) : undefined,
previewHeight: Number.isFinite(Number(candidate.previewHeight)) ? Math.floor(Number(candidate.previewHeight)) : undefined,
@@ -145,6 +157,7 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig
fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim()
? source.fileNamePrefix.trim()
: "processed-",
+ autoAspectResolution: normalizeBatchProcessAutoAspectResolution(source.autoAspectResolution),
discoveredSources,
};
}
diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts
index c5c6c68bf..767a0aee8 100644
--- a/image-studio/frontend/src/types/domain.ts
+++ b/image-studio/frontend/src/types/domain.ts
@@ -57,6 +57,7 @@ export type ThemeMode = "system" | "light" | "dark";
export type CompletionSoundMode = "default" | "custom";
export type EditSourceMode = "manual" | "batch";
export type BatchProcessOutputMode = "source_dir" | "custom_dir";
+export type BatchProcessAutoAspectResolution = "" | "256" | "512" | "1k" | "2k" | "4k";
export type SystemNotificationPermissionState = "default" | "granted" | "denied" | "unsupported";
export interface CompletionSoundConfig {
@@ -165,6 +166,8 @@ export interface BatchProcessSourceImage {
path: string;
name: string;
size: number;
+ width?: number;
+ height?: number;
previewUrl?: string;
previewWidth?: number;
previewHeight?: number;
@@ -177,6 +180,7 @@ export interface BatchProcessConfig {
outputDir: string;
concurrency: number;
fileNamePrefix: string;
+ autoAspectResolution: BatchProcessAutoAspectResolution;
discoveredSources: BatchProcessSourceImage[];
}
diff --git a/image-studio/frontend/test/sizeCapabilities.test.mjs b/image-studio/frontend/test/sizeCapabilities.test.mjs
index 23c158d3e..1cf69627b 100644
--- a/image-studio/frontend/test/sizeCapabilities.test.mjs
+++ b/image-studio/frontend/test/sizeCapabilities.test.mjs
@@ -295,3 +295,19 @@ test("auto aspect can resolve 2K/4K against a reference image ratio", () => {
"3840x1920",
);
});
+
+test("reference resolution selection can use source image ratio directly", () => {
+ const input = {
+ apiMode: "responses",
+ requestPolicy: "openai",
+ imageModelID: "gpt-image-2",
+ };
+ assert.equal(
+ caps.buildReferenceResolutionSizeSelection("2k", { width: 1200, height: 1600 }, input, []),
+ "1448x1928",
+ );
+ assert.equal(
+ caps.buildReferenceResolutionSizeSelection("4k", { width: 1600, height: 1200 }, input, []),
+ "3312x2496",
+ );
+});
diff --git a/image-studio/frontend/test/workspaceRuntime.test.mjs b/image-studio/frontend/test/workspaceRuntime.test.mjs
index 960c13f92..94b9e0eb9 100644
--- a/image-studio/frontend/test/workspaceRuntime.test.mjs
+++ b/image-studio/frontend/test/workspaceRuntime.test.mjs
@@ -42,6 +42,32 @@ test("normalizes api mode and concurrency values", () => {
assert.equal(runtime.normalizeBatchCount(3.8), 3);
assert.equal(runtime.normalizeBatchCount(0), 1);
assert.equal(runtime.normalizeBatchCount(99), 9);
+ assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("2k"), "2k");
+ assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("oops"), "");
+});
+
+test("normalizes batch process config and keeps scanned image dimensions", () => {
+ const value = runtime.normalizeBatchProcessConfig({
+ enabled: true,
+ inputDir: " /tmp/src ",
+ outputMode: "custom_dir",
+ outputDir: " /tmp/out ",
+ concurrency: 3.8,
+ fileNamePrefix: "processed-",
+ autoAspectResolution: "4k",
+ discoveredSources: [
+ { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800 },
+ { path: " ", name: "bad", size: 0 },
+ ],
+ });
+ assert.equal(value.enabled, true);
+ assert.equal(value.inputDir, "/tmp/src");
+ assert.equal(value.outputDir, "/tmp/out");
+ assert.equal(value.concurrency, 3);
+ assert.equal(value.autoAspectResolution, "4k");
+ assert.deepEqual(value.discoveredSources, [
+ { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800, previewUrl: undefined, previewWidth: undefined, previewHeight: undefined },
+ ]);
});
test("patches only the target workspace runtime", () => {
From 86ab13dfa6efd6be01697a548aa1c6ad76fda0b2 Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 22:54:22 +0800
Subject: [PATCH 05/11] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=89=B9=E5=A4=84?=
=?UTF-8?q?=E7=90=86=E7=8A=B6=E6=80=81=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/panel/BatchProcessSection.tsx | 16 ++++++++++++++--
image-studio/frontend/src/state/studioStore.ts | 3 ++-
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
index 8617fdff4..d928fefcb 100644
--- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
+++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
@@ -36,10 +36,22 @@ export function BatchProcessSection({
- setEditSourceMode("manual")}>
+ {
+ setEditSourceMode("manual");
+ setBatchProcess({ ...batchProcess, enabled: false });
+ }}
+ >
普通图生图
- setEditSourceMode("batch")}>
+ {
+ setEditSourceMode("batch");
+ setBatchProcess({ ...batchProcess, enabled: true });
+ }}
+ >
批处理
diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts
index df9570b17..8529ce50b 100644
--- a/image-studio/frontend/src/state/studioStore.ts
+++ b/image-studio/frontend/src/state/studioStore.ts
@@ -1085,7 +1085,8 @@ export const useStudioStore = create
((set, get) => ({
const batchCount = normalizeBatchCount(s.batchCount);
const batchProcess = normalizeBatchProcessConfig(s.batchProcess);
const loopGeneration = normalizeLoopGenerationConfig(s.loopGeneration);
- const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcess.enabled;
+ const batchProcessActive = batchProcess.enabled || !!batchProcess.inputDir.trim() || batchProcess.discoveredSources.length > 0;
+ const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcessActive;
const loopEnabled = !batchProcessEnabled && loopGeneration.enabled;
const requestedJobCount = loopEnabled
? normalizeLoopGenerationCount(loopGeneration.totalCount)
From bbb74bed843c0da1f8612c0428dcadae2ab1d7c8 Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 23:19:52 +0800
Subject: [PATCH 06/11] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=B9=E5=A4=84?=
=?UTF-8?q?=E7=90=86=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/canvas/CanvasStage.tsx | 11 +++--
.../components/panel/BatchProcessSection.tsx | 43 ++++++++++++-------
.../android/canvas/AndroidCanvasStage.tsx | 11 +++--
3 files changed, 44 insertions(+), 21 deletions(-)
diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx
index eec4c3cff..6282f9bac 100644
--- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx
+++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx
@@ -51,7 +51,12 @@ export function CanvasStage() {
});
const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults);
const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults);
- const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length);
+ const liveBatchSlotCount = Math.max(
+ runningJobs.length,
+ batchResults.length,
+ streamPreviewItems.length,
+ Math.min(runningJobs.length, batchResults.length + streamPreviewItems.length),
+ );
const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` }));
for (const item of batchResults) {
const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending");
@@ -63,7 +68,7 @@ export function CanvasStage() {
liveBatchSlots[index] = { type: "preview", item };
}
}
- const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1;
+ const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0;
const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1);
const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1;
const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1;
@@ -474,7 +479,7 @@ export function CanvasStage() {
onSelect={selectBatchResult}
onClose={closeResultGrid}
showClose={!showingLiveBatchGrid}
- title={showingLiveBatchGrid ? `本批预览 · ${jobsCompleted}/${jobsTotal}` : undefined}
+ title={showingLiveBatchGrid ? `当前并发预览 · ${runningJobs.length} 路 · ${jobsCompleted}/${jobsTotal}` : undefined}
/>
)}
{!showingResultGrid && currentImage && compareB && (
diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
index d928fefcb..12b44b275 100644
--- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
+++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
@@ -170,21 +170,34 @@ export function BatchProcessSection({
{batchProcess.autoAspectResolution !== "" ? (
<>
-
- {(["256", "512", "1k", "2k", "4k"] as const).map((value) => (
-
- ))}
+
+
+
+
统一分辨率档位
+
+ 每张图按自己的比例自动适配,但都会使用这里选定的分辨率档位。
+
+
+
+ 当前 {batchProcess.autoAspectResolution.toUpperCase()}
+
+
+
+ {(["256", "512", "1k", "2k", "4k"] as const).map((value) => (
+
+ ))}
+
开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。
diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
index db63f4af2..20c8bdbab 100644
--- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
+++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx
@@ -59,7 +59,12 @@ export function AndroidCanvasStage() {
});
const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults);
const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults);
- const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length);
+ const liveBatchSlotCount = Math.max(
+ runningJobs.length,
+ batchResults.length,
+ streamPreviewItems.length,
+ Math.min(runningJobs.length, batchResults.length + streamPreviewItems.length),
+ );
const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` }));
for (const item of batchResults) {
const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending");
@@ -71,7 +76,7 @@ export function AndroidCanvasStage() {
liveBatchSlots[index] = { type: "preview", item };
}
}
- const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1;
+ const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0;
const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1);
const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1;
const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1;
@@ -472,7 +477,7 @@ export function AndroidCanvasStage() {
onSelect={selectBatchResult}
onClose={closeResultGrid}
showClose={!showingLiveBatchGrid}
- title={showingLiveBatchGrid ? `本批预览 · ${jobsCompleted}/${jobsTotal}` : undefined}
+ title={showingLiveBatchGrid ? `当前并发预览 · ${runningJobs.length} 路 · ${jobsCompleted}/${jobsTotal}` : undefined}
/>
) : null}
{!showingResultGrid && currentImage && compareB ? (
From 6d0066a7939dcb690b4f1ac5f32fc515b519f13e Mon Sep 17 00:00:00 2001
From: lin <1609924163@qq.com>
Date: Mon, 8 Jun 2026 23:42:46 +0800
Subject: [PATCH 07/11] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=89=B9=E5=A4=84?=
=?UTF-8?q?=E7=90=86bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/batch-img2img/README.md | 16 ++++---
image-studio/backend/dialogs.go | 44 +++++++++++++++++++
image-studio/backend/types.go | 4 ++
.../components/panel/BatchProcessSection.tsx | 15 ++++++-
.../src/components/panel/ControlPanel.tsx | 5 ++-
.../panel/DesktopComposeSections.tsx | 3 ++
.../src/components/panel/MacComposePanel.tsx | 3 ++
.../components/panel/MacComposeSources.tsx | 3 ++
.../components/panel/WindowsComposePanel.tsx | 3 ++
.../frontend/src/platform/runtime/host.ts | 8 ++++
.../src/platform/runtime/hostTypes.ts | 3 ++
.../frontend/src/state/studioStore.images.ts | 28 ++++++++++++
.../frontend/src/state/studioStore.ts | 19 ++++----
.../frontend/src/state/studioStore.types.ts | 1 +
14 files changed, 137 insertions(+), 18 deletions(-)
diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md
index 8f5a58e7a..10bb44519 100644
--- a/docs/batch-img2img/README.md
+++ b/docs/batch-img2img/README.md
@@ -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`
diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go
index 6f31bff70..d858f2303 100644
--- a/image-studio/backend/dialogs.go
+++ b/image-studio/backend/dialogs.go
@@ -66,6 +66,50 @@ func (s *Service) OpenImageDialog() (SelectFileResponse, error) {
return resp, nil
}
+func (s *Service) OpenImagesDialog() (SelectFilesResponse, error) {
+ paths, err := runtime.OpenMultipleFilesDialog(s.ctx, runtime.OpenDialogOptions{
+ Title: "选择批处理源图片",
+ Filters: []runtime.FileFilter{
+ {DisplayName: "支持的图片 (*.png;*.jpg;*.jpeg;*.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp"},
+ {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
+ },
+ })
+ if err != nil {
+ return SelectFilesResponse{}, err
+ }
+ if len(paths) == 0 {
+ return SelectFilesResponse{}, nil
+ }
+ files := make([]BatchInputImage, 0, len(paths))
+ for _, path := range paths {
+ if strings.TrimSpace(path) == "" {
+ continue
+ }
+ info, statErr := os.Stat(path)
+ if statErr != nil || info.IsDir() {
+ continue
+ }
+ item := BatchInputImage{
+ Path: path,
+ Name: filepath.Base(path),
+ Size: info.Size(),
+ }
+ if cfg, cfgErr := imageConfig(path); cfgErr == nil {
+ item.Width = cfg.Width
+ item.Height = cfg.Height
+ }
+ if info.Size() > 0 && info.Size() <= maxDialogReadBytes {
+ if preview, previewErr := s.registerImportedPreview(path); previewErr == nil {
+ item.PreviewURL = preview.PreviewURL
+ item.PreviewWidth = preview.PreviewWidth
+ item.PreviewHeight = preview.PreviewHeight
+ }
+ }
+ files = append(files, item)
+ }
+ return SelectFilesResponse{Files: files}, nil
+}
+
func (s *Service) ChooseBatchInputDir() (BatchInputDirectory, error) {
if s.ctx == nil {
return BatchInputDirectory{}, errors.New("服务未启动")
diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go
index 4abec4c67..0ede8f858 100644
--- a/image-studio/backend/types.go
+++ b/image-studio/backend/types.go
@@ -215,6 +215,10 @@ type BatchInputDirectory struct {
Images []BatchInputImage `json:"images"`
}
+type SelectFilesResponse struct {
+ Files []BatchInputImage `json:"files"`
+}
+
// ImportedImage describes a freshly imported (drag-dropped or pasted) image.
type ImportedImage struct {
Path string `json:"path"`
diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
index 12b44b275..12f978fc7 100644
--- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
+++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx
@@ -1,4 +1,4 @@
-import { FolderOpen, RefreshCw } from "lucide-react";
+import { FolderOpen, Images, RefreshCw } from "lucide-react";
import type { BatchProcessConfig, EditSourceMode } from "../../types/domain";
import { Section, Seg, SegItem } from "./panelChrome";
@@ -16,6 +16,7 @@ export function BatchProcessSection({
setEditSourceMode,
setBatchProcess,
onChooseInputDir,
+ onChooseInputFiles,
onChooseOutputDir,
onRefreshInputDir,
usesFluentUI = false,
@@ -26,6 +27,7 @@ export function BatchProcessSection({
setEditSourceMode: (mode: EditSourceMode) => void;
setBatchProcess: (next: BatchProcessConfig) => void;
onChooseInputDir: () => void;
+ onChooseInputFiles?: () => void;
onChooseOutputDir?: () => void;
onRefreshInputDir: () => void;
usesFluentUI?: boolean;
@@ -86,6 +88,15 @@ export function BatchProcessSection({
>
选择目录
+ {onChooseInputFiles ? (
+
+ ) : null}