diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76a0c1dbd..d87c592e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,22 @@ jobs: echo "artifact_path=${artifact_path}" } >> "$GITHUB_OUTPUT" + - name: Sign Windows artifact + if: matrix.goos == 'windows' && secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 != '' && secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD != '' + shell: pwsh + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL: ${{ vars.IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL }} + run: | + ./scripts/sign-windows-binary.ps1 -Path "${{ steps.package.outputs.artifact_path }}" + + - name: Warn on unsigned Windows artifacts + if: matrix.goos == 'windows' && (secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 == '' || secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD == '') + shell: bash + run: | + echo "::warning::Windows artifact is unsigned. Windows 11 Smart App Control / SmartScreen may block it." + - name: Upload build artifact uses: actions/upload-artifact@v4 with: @@ -345,6 +361,22 @@ jobs: echo "artifact_path=${artifact_path}" } >> "$GITHUB_OUTPUT" + - name: Sign Gio Windows artifact + if: matrix.goos == 'windows' && secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 != '' && secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD != '' + shell: pwsh + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL: ${{ vars.IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL }} + run: | + ./scripts/sign-windows-binary.ps1 -Path "${{ steps.package.outputs.artifact_path }}" + + - name: Warn on unsigned Gio Windows artifacts + if: matrix.goos == 'windows' && (secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 == '' || secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD == '') + shell: bash + run: | + echo "::warning::Windows artifact is unsigned. Windows 11 Smart App Control / SmartScreen may block it." + - name: Upload Gio artifact uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 8d68ce713..e2e3bc2f6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 1. 安装应用 - 稳定版本:到 [RoseKhlifa/Image-Studio Releases](https://github.com/RoseKhlifa/Image-Studio/releases) 下载。 - 抢先体验当前分支的最新改动:到 [DR-lin-eng/Image-Studio Actions · release.yml](https://github.com/DR-lin-eng/Image-Studio/actions/workflows/release.yml) 下载最近一次成功构建的 artifact。 + Windows 上这类 CI `exe` 如果没有签名,可能会被 Win11 Smart App Control / SmartScreen 拦截,因此只建议用于内部测试。 - 各平台安装包区别、命名规则和选择建议见 [docs/packages.md](./docs/packages.md)。 2. 首次启动后打开「上游配置」,填写 API 形态、BASE_URL、API Key、文本模型 ID、图像模型 ID。 3. 根据上游能力选择 API 形态 diff --git a/docs/build.md b/docs/build.md index 13d197ff7..c4f0fdab0 100644 --- a/docs/build.md +++ b/docs/build.md @@ -217,6 +217,28 @@ node scripts/live-verify.mjs - 单独构建一个 Android release APK。 - tag 为 `v*` 时将所有产物附加到 GitHub Release。 +### Windows 签名 + +Windows 11 的 Smart App Control / SmartScreen 会重点拦截无法验证发布者的 `exe`。仓库里的 release workflow 现在约定: + +- 如果配置了 Windows 签名证书,workflow 会自动对 Windows `exe` 做 Authenticode 签名并校验。 +- 如果没有配置签名证书,workflow 仍然继续产出 Windows artifact,但会在日志里明确警告这些 `exe` 可能被 Win11 拦截。 + +需要配置的 GitHub Actions secrets: + +| 变量 | 用途 | +|---|---| +| `IMAGE_STUDIO_WINDOWS_CERT_BASE64` | Base64 编码后的 `.pfx` 证书内容。 | +| `IMAGE_STUDIO_WINDOWS_CERT_PASSWORD` | `.pfx` 密码。 | + +可选 GitHub Actions variable: + +| 变量 | 用途 | +|---|---| +| `IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL` | RFC 3161 时间戳地址;默认 `http://timestamp.acs.microsoft.com`。 | + +签名步骤由 `scripts/sign-windows-binary.ps1` 执行,内部会调用 `signtool sign`、`signtool verify /pa /all` 和 `Get-AuthenticodeSignature` 做校验。 + 平台内核验证 workflow: - `.github/workflows/verify-platform-kernel.yml` diff --git a/docs/features.md b/docs/features.md index 09ef136be..320428d8c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,6 +7,7 @@ - 文生图与图生图,支持多张参考图。 - 输入图源:文件对话框、拖拽窗口、剪贴板粘贴、历史复用、双击历史项设为源图。 - 参数:Auto 与固定尺寸、Auto / high / medium / low 质量、PNG / JPEG / WebP 输出格式、seed、negative prompt、风格 chip。 +- 尺寸语义:比例与分辨率是同一个 `size` 的拆分视图;当比例切到 `Auto` 时,底层尺寸会整体交给上游决定。细节见 [size-auto.md](./size-auto.md)。 - 参数预设:可在提示词上方的独立预设分区里展开查看、直接切换,并通过保存/新建/管理弹窗维护常用尺寸、质量、输出格式、风格与批量张数组合。 - 比例管理:内置常用宽高比,也支持通过「自定义比例」弹窗添加多个用户自定义宽高比并持久化保存;新增比例会立即出现在参数按钮区,并按当前 1K / 2K / 4K 档位自动换算尺寸。 - 双 API 形态: diff --git a/docs/packages.md b/docs/packages.md index 11ddadc01..64bb1d03f 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -14,6 +14,8 @@ 这类构建通常会更快包含当前开发分支上的改动,但稳定性和回归覆盖不如正式 release,建议只在测试环境使用。 +Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 Authenticode 签名,在 Windows 11 上很容易被 Smart App Control 或 SmartScreen 直接拦截,提示“无法确认其编写人”或“可能不安全的应用”。这类包不应作为对外分发的正式安装包。 + ## 安装包命名规则 当前 release workflow 会产出带版本号前缀的安装包,格式大致如下: @@ -44,6 +46,7 @@ - Wails 桌面版依赖 WebView2 Runtime;Windows 10+ 通常已预装。 - ARM64 设备优先下载 `windows-arm64`,避免 x64 仿真带来的额外开销。 +- 对外分发请优先使用带有效 Authenticode 签名的正式 release;未签名的 CI `exe` 在 Windows 11 上可能被智能应用控制直接阻止运行。 ### macOS diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 000000000..8b067f01b --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,179 @@ +# 隐私政策 + +最后更新:2026-06-06 + +本隐私政策适用于本仓库当前提供的 Image Studio 客户端,包括桌面版(Windows、macOS、Linux)与 Android 壳层版本。若你使用仓库中的浏览器预览/调试环境,部分本地存储行为会与正式客户端不同,本文也会一并说明。 + +## 1. 基本原则 + +- Image Studio 不提供开发者自营的内置生图服务。 +- 你输入的提示词、参考图、蒙版、模型参数等数据,默认只会保存在你的设备本地,或发送到你自己配置的上游 `BASE_URL`。 +- 当前仓库代码中未集成广告 SDK、统计埋点 SDK、崩溃上报 SDK,也不会自动把你的提示词、图片或 API Key 上传到开发者控制的独立采集服务器。 + +## 2. 我们会处理哪些数据 + +Image Studio 在本地可能处理以下数据: + +- 上游配置与偏好设置:例如 `BASE_URL`、API 形态、文本/图像模型 ID、请求策略、并发限制、代理模式与代理地址、主题、字号、输出格式、预设、自定义比例、输出目录、受信任输出目录、是否保留日志、忽略的版本号、完成提示音配置等。 +- API Key: + - 桌面版默认使用系统安全存储,不写入普通 JSON 配置。 + - Android 版保存在应用私有 `SharedPreferences` 中。 + - 浏览器预览环境没有系统密钥库时,会退回浏览器本地存储,仅适合调试。 +- 生成与历史数据:例如提示词、优化后提示词、模式、尺寸、质量、风格、种子、负面提示词、源图片路径、生成结果路径、原始响应日志路径、历史时间戳等。 +- 本地图片副本:为了支持拖拽导入、剪贴板粘贴、历史预览、旋转/翻转/裁剪、时间线回看等功能,应用可能在本地保存导入图片、副本、缩略图、预览图或历史全图数据。 +- 可选用户标识:如果你主动填写“用户标识”,该值会随请求发往你配置的上游,用于上游侧的安全审计或滥用防护。Responses API 下映射为 `safety_identifier`,Images API 下映射为 `user`。 + +## 3. 本地存储位置 + +### 桌面版 + +- Windows: + - 默认输出目录通常为 `Documents\\Image Studio` + - 生成图片位于 `images/` + - 原始响应日志位于 `log/` + - 缩略图/预览图位于 `thumbs/`、`previews/` + - 兼容状态文件位于 `compat/state.json` + - 导入图片与本地编辑派生文件位于 `imports/` + - WebView2 本地数据位于 `webview/` +- macOS: + - 默认输出目录通常为 `~/Pictures/Image Studio` + - 应用状态、导入文件等通常位于 `~/Library/Application Support/image-studio/` +- Linux: + - 默认输出目录通常为 `~/Pictures/Image Studio` + - 应用状态、导入文件等通常位于 `~/.config/image-studio/` + +### Android 版 + +- 默认输出目录通常为应用私有目录 `Android/data/<应用包名>/files/Pictures/ImageStudio` +- 导入图片副本通常位于应用私有目录下的 `files/imports` +- API Key 与输出目录等配置保存在应用私有 `SharedPreferences` +- 在 Android 10 及以上版本,如果你执行“保存图片”操作,图片可能写入系统相册 `Pictures/ImageStudio` + +### 浏览器预览/调试环境 + +- 历史与图片缓存可能保存在浏览器 `IndexedDB`(数据库名为 `image-studio`) +- 设置项可能保存在浏览器 `localStorage`(键名前缀通常为 `gptcodex.`) +- 在无原生宿主能力时,浏览器预览环境可能用 `localStorage` 暂存 API Key;这不是正式客户端的安全存储方案 + +## 4. 何时会发生网络请求 + +### 4.1 发送到你配置的上游 + +当你执行以下操作时,应用会把相关数据发送到你配置的 `BASE_URL`: + +- 测试连接 +- 文生图 +- 图生图 +- Prompt 优化 + +可能发送的数据包括: + +- API Key(作为 `Authorization: Bearer ...` 请求头) +- 提示词、参考图、蒙版 +- 模型 ID、尺寸、质量、输出格式、风格、种子、负面提示词等生成参数 +- 你主动填写的用户标识 + +这些请求是直接发往你配置的上游,或按你的代理设置经代理转发。上游服务与代理服务如何处理这些数据,不受 Image Studio 控制,你应自行评估其隐私与合规政策。 + +### 4.2 桌面版更新检查 + +桌面版当前版本支持检查新版本。检查更新时,会向 GitHub 请求: + +- `https://api.github.com/repos/RoseKhlifa/Image-Studio/releases/latest` + +这会使 GitHub 获取你的网络请求元数据,例如 IP 地址、请求时间、User-Agent 或其他标准 HTTP 信息。 + +### 4.3 外部链接 + +当你主动打开发布页、Issues 或其他外部链接时,系统浏览器会访问对应站点;相关站点会按其自身政策处理访问数据。 + +## 5. 不会自动上传什么 + +在当前仓库实现中,应用不会自动执行以下行为: + +- 不会把你的 API Key 上传给开发者自建统计平台 +- 不会把你的提示词或生成图片自动同步到开发者服务器 +- 不会集成第三方广告网络 +- 不会内置崩溃上报或行为埋点 SDK + +但请注意: + +- 你主动配置的上游服务本身可能记录请求日志 +- 你主动导出历史、保存图片、复制到外部目录后,相关文件将离开应用私有范围 + +## 6. 日志、原始响应与排错数据 + +桌面版会在生成期间把上游原始响应写入输出目录下的 `log/` 子目录,以便你排查问题,例如: + +- Responses API 的 SSE 文本响应 +- Images API 的 JSON 响应 + +关于日志保留: + +- 如果你开启“保留日志”,这些文件会继续保留在本地 +- 如果你关闭“保留日志”,桌面端会在应用关闭时清理受管理的日志目录 +- 即使关闭“保留日志”,在应用运行期间,当前请求的临时原始响应文件仍可能短暂存在,直到应用退出并执行清理 + +## 7. Android 权限与系统备份 + +Android 版当前声明的主要权限包括: + +- `INTERNET`:用于访问你配置的上游服务 +- `READ_EXTERNAL_STORAGE`(仅 Android 12 及以下生效):用于读取你选择的本地图片 + +另外,Android 清单当前启用了系统备份能力: + +- `android:allowBackup="true"` +- 当前备份规则排除了数据库,但没有排除全部应用私有文件与 `SharedPreferences` + +这意味着在你的 Android 系统允许云备份或设备迁移时,以下数据可能随系统机制参与备份/迁移: + +- 本地配置 +- Android 端保存的 API Key +- 应用私有目录中的导入文件或其他本地文件 + +是否实际发生备份,还取决于你的 Android 系统版本、账户设置、加密能力与设备迁移流程。 + +## 8. 数据安全 + +- 桌面版 API Key 默认写入系统密钥库(如 Keychain、Credential Manager、Secret Service) +- 桌面版兼容状态文件与多数受管理文件使用仅当前用户可读写的权限创建 +- Android 私有目录与 `SharedPreferences` 受 Android 应用沙箱保护 + +但你仍需理解以下风险: + +- 你选择的上游或代理可能位于第三方服务器 +- 你主动保存到公共目录、系统相册或导出到其他位置的文件,可能被其他应用、系统服务或具有相应权限的用户访问 +- 浏览器预览环境的安全性低于正式桌面客户端 + +## 9. 你的控制权 + +你可以通过以下方式控制本地数据: + +- 在应用设置中修改或清空 API Key +- 在应用中清空历史记录 +- 关闭“保留日志” +- 修改输出目录 +- 手动删除输出目录中的图片、日志、缩略图与导出的历史文件 +- 手动删除应用状态目录中的 `compat/state.json`、`imports/`、浏览器存储或 WebView 数据 +- 在 Android 中使用“清除存储”或卸载应用删除应用私有数据 +- 单独删除你已经保存到系统相册、公共目录或外部位置的图片与 JSON 文件 + +## 10. 历史导入与导出 + +当你主动使用历史导出功能时: + +- 桌面版会通过系统保存对话框写出 JSON 文件 +- Android 版会把导出的 JSON 写到当前输出目录 + +当你主动导入历史文件时,文件内容会被读入本地并合并到本地历史中。导入/导出均属于用户主动操作,不会自动执行。 + +## 11. 适用范围变更 + +如果后续版本引入新的联网能力、账户系统、同步服务、统计服务或新的第三方 SDK,本文件应随代码行为一起更新。 + +## 12. 联系方式 + +如果你对本项目的隐私行为有疑问,建议通过项目 Issues 反馈: + +- [RoseKhlifa/Image-Studio Issues](https://github.com/RoseKhlifa/Image-Studio/issues) diff --git a/docs/size-auto.md b/docs/size-auto.md new file mode 100644 index 000000000..63dd9efbe --- /dev/null +++ b/docs/size-auto.md @@ -0,0 +1,80 @@ +# 比例 Auto 与分辨率的关系 + +这份文档专门解释一个常见疑问:为什么把「比例」切到 `Auto` 以后,分辨率也不能再像独立开关一样单独锁定。 + +## 先说结论 + +- Image Studio 底层保存和发送的是一个统一的 `size` 字段,不是分别发送“比例”和“分辨率”两个独立参数。 +- 选择具体比例和具体分辨率时,应用会先把两者组合成一个明确的像素尺寸,例如 `1536x1024`、`2048x1152`、`2160x3840`。 +- 当你显式选择 `Auto` 时,底层 `size` 会直接变成 `"auto"`,意思是“把最终尺寸交给上游决定”。 +- 一旦 `size` 已经是 `"auto"`,应用本地就不再持有一个可继续锁定的明确像素尺寸,所以“分辨率”也不会再保留成一个独立的固定值。 + +## 为什么看起来像“比例自动后,分辨率失控了” + +界面上有“比例”和“分辨率”两个选择区,但它们只是同一个 `size` 的两种拆分视图,不是两条彼此独立的请求字段。 + +可以把它理解成这样: + +- `1:1 + 1K` -> `1024x1024` +- `3:2 + 1K` -> `1536x1024` +- `16:9 + 2K` -> `2048x1152` +- `9:16 + 4K` -> `2160x3840` +- `Auto` -> `"auto"` + +也就是说,`Auto` 不是“只自动比例,但仍然固定分辨率”,而是“整个尺寸都交给上游”。 + +这也是为什么当前实现里,只要 `size === "auto"`: + +- 当前激活比例会显示为 `Auto` +- 当前激活分辨率也会显示为 `Auto` + +不是界面忘了记住你之前的分辨率,而是底层已经没有一个同时满足“仍是 Auto”又“还固定 2K / 4K”的单一 `size` 值可发给上游。 + +## 两种 API 形态都是同一个语义 + +无论当前走的是: + +- Responses API +- Images API + +最终发给上游的尺寸都是单个 `size` 值。 + +所以这里不是某个平台面板的特殊行为,也不是桌面端和 Android 端语义不一致,而是整个应用共享的尺寸模型就是这样设计的。 + +## 如果想重新拿回分辨率控制,应该怎么做 + +只要你点一个明确的比例,或点一个明确的分辨率,应用就会把状态从 `auto` 提升回一个具体像素尺寸。 + +当前规则是: + +- 从 `Auto` 状态点击具体比例: + - 如果分辨率这边还是 `Auto`,应用会先用 `1K` 作为默认档位再生成具体尺寸。 + - 例如点击 `9:16`,会得到 `864x1536`。 +- 从 `Auto` 状态点击具体分辨率: + - 如果比例这边还是 `Auto`,应用会先用 `1:1` 作为默认比例再生成具体尺寸。 + - 例如点击 `2K`,会得到 `2048x2048`。 +- 只有当你再次明确点击 `Auto`,才会重新回到“由上游决定尺寸”的状态。 + +## 哪些情况是正常语义,哪些情况才算 bug + +正常语义: + +- 你主动选择 `Auto` 后,分辨率不再作为独立锁定值保留。 +- 不同模型能提供的比例和分辨率组合不同。 +- 某些链路只提供基础尺寸,不提供 `2K` / `4K` / 自定义比例。 + +这类情况通常是模型能力或链路能力限制,不是交互故障。 + +真正算 bug 的情况是: + +- 你当前在 `Auto` +- 你已经点击了一个明确比例或明确分辨率 +- 结果状态仍然卡在 `Auto`,没有退出 + +这个“点了具体项却仍卡在 Auto”的互锁问题已经在 2026-05-30 修过;当前桌面端和 Android 端都应该在点击明确项后正常退出 `Auto`。 + +## 使用建议 + +- 想让上游自己决定最终宽高,就用 `Auto`,同时接受“本地不再锁定具体分辨率”。 +- 想稳定控制输出像素尺寸,就不要停留在 `Auto`,而是至少选择一个明确比例或明确分辨率。 +- 如果你需要 `2K`、`4K`、自定义比例这类能力,先确认当前模型和请求策略本身支持这些选项。 diff --git a/docs/usage.md b/docs/usage.md index 9f4d8f007..f842d0031 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -80,6 +80,7 @@ 2. 输入 prompt。 3. 选择比例、质量、输出格式、风格。 - 内置比例不够时,可以打开「自定义比例」弹窗,新增并保存常用宽高比。 + - 如果想理解“为什么比例切到 Auto 后,分辨率也不能再单独锁定”,见 [size-auto.md](./size-auto.md)。 4. 根据需要设置 seed 或 negative prompt。 5. 点击「生成」,或使用 `Cmd+Enter` / `Ctrl+Enter`。 diff --git a/image-studio/frontend/src/app/App.tsx b/image-studio/frontend/src/app/App.tsx index bcaaaccab..7acd4ffa0 100644 --- a/image-studio/frontend/src/app/App.tsx +++ b/image-studio/frontend/src/app/App.tsx @@ -8,6 +8,7 @@ import { DropImportOverlay } from "./components/DropImportOverlay"; import { PlatformWorkspace } from "./components/PlatformWorkspace"; import { HistoryTimelineModal } from "../components/history/HistoryTimelineModal"; import { CustomAspectRatioGate } from "./gates/CustomAspectRatioGate"; +import { CustomSizeGate } from "./gates/CustomSizeGate"; import { ResultDetailGate } from "./gates/ResultDetailGate"; import { SavePromptGate } from "./gates/SavePromptGate"; import { SettingsPanelGate } from "./gates/SettingsPanelGate"; @@ -48,6 +49,7 @@ export default function App() { {dragHover ? : null} + diff --git a/image-studio/frontend/src/app/dev/previewScenario.ts b/image-studio/frontend/src/app/dev/previewScenario.ts index 4502df2de..b1f602487 100644 --- a/image-studio/frontend/src/app/dev/previewScenario.ts +++ b/image-studio/frontend/src/app/dev/previewScenario.ts @@ -87,6 +87,7 @@ export function applyMacWorkspacePreviewToStore(workspaceId = "preview-workspace isTestingKey: false, isOptimizingPrompt: false, customAspectRatioModalOpen: false, + customSizeModalOpen: false, upstreamModalOpen: false, upstreamReturnTarget: "app", starPromptOpen: false, diff --git a/image-studio/frontend/src/app/gates/CustomSizeGate.tsx b/image-studio/frontend/src/app/gates/CustomSizeGate.tsx new file mode 100644 index 000000000..03af0a442 --- /dev/null +++ b/image-studio/frontend/src/app/gates/CustomSizeGate.tsx @@ -0,0 +1,15 @@ +import { lazy, Suspense } from "react"; +import { useStudioStore } from "../../state/studioStore"; + +const CustomSizeModal = lazy(() => import("../../components/panel/CustomSizeModal").then((module) => ({ default: module.CustomSizeModal }))); + +export function CustomSizeGate() { + const open = useStudioStore((state) => state.customSizeModalOpen); + if (!open) return null; + + return ( + + + + ); +} diff --git a/image-studio/frontend/src/components/panel/ControlPanel.tsx b/image-studio/frontend/src/components/panel/ControlPanel.tsx index f8e4dcacd..564d3bd47 100644 --- a/image-studio/frontend/src/components/panel/ControlPanel.tsx +++ b/image-studio/frontend/src/components/panel/ControlPanel.tsx @@ -21,11 +21,13 @@ import { availableResolutionPresets, buildAspectSizeSelection, buildResolutionSizeSelection, + deriveExactSizeSelection, deriveAspectPreset, deriveResolutionPreset, listAspectPresetOptions, normalizeSizeSelection, supportsCustomAspectRatios, + supportsPreciseSizeControl, } from "./sizeCapabilities"; export function ControlPanel({ @@ -44,6 +46,7 @@ export function ControlPanel({ setField, clearError, pushToast, selectSourceImage, removeSource, clearSources, viewSourceOnCanvas, openCustomAspectRatioModal, + openCustomSizeModal, openUpstreamConfig, submit, cancel, retryLast, optimizePrompt, } = useStudioStore(); @@ -73,12 +76,18 @@ export function ControlPanel({ const normalizedQuality = normalizeQualitySelection(quality, imageModelID); const qualityOptions = availableQualityOptions(imageModelID); const allowCustomAspectRatios = supportsCustomAspectRatios(capabilityInput); + const allowPreciseSizeControl = supportsPreciseSizeControl(capabilityInput); const activeStyleLabel = STYLE_CHIPS.find((item) => item.id === styleTag)?.label ?? styleTag; const aspectOptions = listAspectPresetOptions(capabilityInput, customAspectRatios); - const activeAspect = deriveAspectPreset(normalizedSize, customAspectRatios); - const activeResolution = deriveResolutionPreset(normalizedSize); - const activeAspectLabel = aspectPresetLabel(activeAspect, customAspectRatios); - const activeResolutionLabel = RESOLUTION_PRESETS.find((item) => item.value === activeResolution)?.label ?? activeResolution; + const exactSize = deriveExactSizeSelection(normalizedSize, capabilityInput, customAspectRatios); + const derivedAspect = deriveAspectPreset(normalizedSize, customAspectRatios); + const derivedResolution = deriveResolutionPreset(normalizedSize); + const activeAspect = exactSize ? null : derivedAspect; + const activeResolution = exactSize ? null : derivedResolution; + const activeAspectLabel = exactSize ? "精确尺寸" : aspectPresetLabel(derivedAspect, customAspectRatios); + const activeResolutionLabel = exactSize + ? exactSize.label + : (RESOLUTION_PRESETS.find((item) => item.value === derivedResolution)?.label ?? derivedResolution); const activeQualityLabel = qualityOptions.find((item) => item.value === normalizedQuality)?.label ?? normalizedQuality; const availableResolutions = availableResolutionPresets(capabilityInput); const optimizeReady = !!( @@ -104,8 +113,8 @@ export function ControlPanel({ function handleAspectSelect(aspect: typeof activeAspect) { setField("size", buildAspectSizeSelection( - aspect, - activeResolution, + aspect ?? derivedAspect, + derivedResolution, capabilityInput, customAspectRatios, )); @@ -113,8 +122,8 @@ export function ControlPanel({ function handleResolutionSelect(resolution: typeof activeResolution) { setField("size", buildResolutionSizeSelection( - activeAspect, - resolution, + derivedAspect, + resolution ?? derivedResolution, capabilityInput, customAspectRatios, )); @@ -205,6 +214,7 @@ export function ControlPanel({ activeAspect={activeAspect} aspectOptions={aspectOptions} activeResolution={activeResolution} + exactSizeLabel={exactSize?.label ?? null} apiMode={apiMode} availableResolutions={availableResolutions} batchCount={batchCount} @@ -214,7 +224,9 @@ export function ControlPanel({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} + onOpenCustomSizeModal={openCustomSizeModal} usesFluentUI={usesFluentUI} mode={mode} onPreviewSource={(index) => void viewSourceOnCanvas(index)} @@ -241,6 +253,7 @@ export function ControlPanel({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSize?.label ?? null} activeQualityLabel={activeQualityLabel} availableResolutions={availableResolutions} batchCount={batchCount} @@ -250,7 +263,9 @@ export function ControlPanel({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} + onOpenCustomSizeModal={openCustomSizeModal} mode={mode} onPreviewSource={(index) => void viewSourceOnCanvas(index)} onRemoveSource={removeSource} @@ -276,6 +291,7 @@ export function ControlPanel({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSize?.label ?? null} activeQualityLabel={activeQualityLabel} availableResolutions={availableResolutions} batchCount={batchCount} @@ -289,7 +305,9 @@ export function ControlPanel({ handleAspectSelect={handleAspectSelect} handleResolutionSelect={handleResolutionSelect} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} + onOpenCustomSizeModal={openCustomSizeModal} selectSourceImage={selectSourceImage} clearSources={clearSources} viewSourceOnCanvas={(index) => void viewSourceOnCanvas(index)} diff --git a/image-studio/frontend/src/components/panel/CustomSizeModal.tsx b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx new file mode 100644 index 000000000..cfadaed1b --- /dev/null +++ b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { Ruler } from "lucide-react"; +import { useStudioStore } from "../../state/studioStore"; +import { + formatSizeValue, + MAX_EXACT_SIZE, + MIN_EXACT_SIZE, + parseSizeValue, + reduceAspectRatioLabel, +} from "./sizeCapabilities"; +import { Modal } from "../common/Modal"; + +const DEFAULT_EXACT_SIZE = { width: 1024, height: 1024 }; + +export function CustomSizeModal() { + const open = useStudioStore((state) => state.customSizeModalOpen); + const size = useStudioStore((state) => state.size); + const applyCustomSize = useStudioStore((state) => state.applyCustomSize); + const close = useStudioStore((state) => state.closeCustomSizeModal); + const [widthInput, setWidthInput] = useState(""); + const [heightInput, setHeightInput] = useState(""); + + useEffect(() => { + if (!open) return; + const parsed = size === "auto" ? DEFAULT_EXACT_SIZE : (parseSizeValue(size) ?? DEFAULT_EXACT_SIZE); + setWidthInput(String(parsed.width)); + setHeightInput(String(parsed.height)); + }, [open, size]); + + const ratioHint = useMemo(() => { + const width = Number(widthInput); + const height = Number(heightInput); + return reduceAspectRatioLabel(width, height); + }, [heightInput, widthInput]); + + const multipleHint = useMemo(() => { + const width = Number(widthInput); + const height = Number(heightInput); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return ""; + return width % 8 === 0 && height % 8 === 0 + ? "当前尺寸已对齐到 8 的倍数。" + : "当前尺寸不是 8 的倍数,部分上游可能会拒绝。"; + }, [heightInput, widthInput]); + + const handleApply = (event?: FormEvent) => { + event?.preventDefault(); + applyCustomSize(Number(widthInput), Number(heightInput)); + }; + + return ( + +
+
+ 直接指定要传给上游的 size。适合比例按钮不够精确的场景。 + 点击比例或分辨率预设后,会自动退出精确尺寸模式。 +
+ +
+
+ + 当前工作区尺寸 +
+
+ {size === "auto" ? "Auto(由上游决定)" : formatSizeValue(size)} +
+
+ +
+
+
+
设置精确尺寸
+
+ 请输入 {MIN_EXACT_SIZE} 到 {MAX_EXACT_SIZE} 之间的整数像素值。 +
+
+ {ratioHint ? ( +
+ 比例 {ratioHint} +
+ ) : null} +
+ +
+ +
×
+ + +
+ + {multipleHint ? ( +
+ {multipleHint} +
+ ) : null} +
+
+
+ ); +} diff --git a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx index 569adaf55..07078b5d7 100644 --- a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx +++ b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx @@ -21,7 +21,9 @@ export function DesktopComposeSections({ activeAspect, aspectOptions, activeResolution, + exactSizeLabel, allowCustomAspectRatios, + allowPreciseSizeControl, apiMode, batchCount, clearSources, @@ -30,6 +32,7 @@ export function DesktopComposeSections({ handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, onRemoveSource, onPreviewSource, mode, @@ -44,10 +47,12 @@ export function DesktopComposeSections({ usesFluentUI, availableResolutions, }: { - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; + exactSizeLabel?: string | null; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; apiMode: "responses" | "images"; batchCount: number; clearSources: () => void; @@ -56,6 +61,7 @@ export function DesktopComposeSections({ handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; usesFluentUI: boolean; mode: Mode; onRemoveSource: (index: number) => void; @@ -139,6 +145,17 @@ export function DesktopComposeSections({
+ {allowPreciseSizeControl ? ( +
+ +
+ ) : null} {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( ))} + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? (

{sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} @@ -175,20 +197,25 @@ export function DesktopComposeSections({ label="出图张数" trailing={{batchCount}x} > -

+
{[1, 2, 4, 6, 8, 9].map((count) => ( ))}
diff --git a/image-studio/frontend/src/components/panel/MacComposePanel.tsx b/image-studio/frontend/src/components/panel/MacComposePanel.tsx index 818b09bcf..aebcb4a36 100644 --- a/image-studio/frontend/src/components/panel/MacComposePanel.tsx +++ b/image-studio/frontend/src/components/panel/MacComposePanel.tsx @@ -17,8 +17,10 @@ export function MacComposePanel({ aspectOptions, activeResolution, activeResolutionLabel, + exactSizeLabel, activeQualityLabel, allowCustomAspectRatios, + allowPreciseSizeControl, availableResolutions, batchCount, mode, @@ -31,6 +33,7 @@ export function MacComposePanel({ handleAspectSelect, handleResolutionSelect, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, selectSourceImage, clearSources, viewSourceOnCanvas, @@ -43,13 +46,15 @@ export function MacComposePanel({ setMacComposeOpen: React.Dispatch>; styleTag: string; activeStyleLabel: string; - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; activeAspectLabel: string; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; activeResolutionLabel: string; + exactSizeLabel?: string | null; activeQualityLabel: string; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; batchCount: number; mode: string; @@ -62,6 +67,7 @@ export function MacComposePanel({ handleAspectSelect: (aspect: AspectPreset) => void; handleResolutionSelect: (resolution: ResolutionPreset) => void; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; selectSourceImage: () => void; clearSources: () => void; viewSourceOnCanvas: (index: number) => void; @@ -91,7 +97,9 @@ export function MacComposePanel({ activeAspect={activeAspect} aspectOptions={aspectOptions} activeResolution={activeResolution} + exactSizeLabel={exactSizeLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} apiMode={apiMode} availableResolutions={availableResolutions} batchCount={batchCount} @@ -99,6 +107,7 @@ export function MacComposePanel({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} onOpenCustomAspectRatioModal={onOpenCustomAspectRatioModal} + onOpenCustomSizeModal={onOpenCustomSizeModal} quality={quality} qualityOptions={qualityOptions} requestPolicy={requestPolicy} diff --git a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx index 2ffff5f66..a1ae401db 100644 --- a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx +++ b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx @@ -13,7 +13,9 @@ export function MacComposeStyleAndSize({ activeAspect, aspectOptions, activeResolution, + exactSizeLabel, allowCustomAspectRatios, + allowPreciseSizeControl, apiMode, availableResolutions, batchCount, @@ -21,6 +23,7 @@ export function MacComposeStyleAndSize({ handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, quality, qualityOptions, requestPolicy, @@ -29,10 +32,12 @@ export function MacComposeStyleAndSize({ Seg, SegItem, }: { - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; + exactSizeLabel?: string | null; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; apiMode: APIMode; availableResolutions: ResolutionPreset[]; batchCount: number; @@ -40,6 +45,7 @@ export function MacComposeStyleAndSize({ handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; quality: QualityValue; qualityOptions: Array<{ value: QualityValue; label: string }>; requestPolicy: RequestPolicy; @@ -116,7 +122,18 @@ export function MacComposeStyleAndSize({
-
分辨率
+
+ 分辨率 + {allowPreciseSizeControl ? ( + + ) : null} +
{RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( ))} + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? (

{sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} diff --git a/image-studio/frontend/src/components/panel/WindowsComposePanel.tsx b/image-studio/frontend/src/components/panel/WindowsComposePanel.tsx index 8e8a7caef..3196487c6 100644 --- a/image-studio/frontend/src/components/panel/WindowsComposePanel.tsx +++ b/image-studio/frontend/src/components/panel/WindowsComposePanel.tsx @@ -13,8 +13,10 @@ export function WindowsComposePanel({ aspectOptions, activeResolution, activeResolutionLabel, + exactSizeLabel, activeQualityLabel, allowCustomAspectRatios, + allowPreciseSizeControl, availableResolutions, batchCount, clearSources, @@ -23,6 +25,7 @@ export function WindowsComposePanel({ handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, mode, onPreviewSource, onRemoveSource, @@ -39,13 +42,15 @@ export function WindowsComposePanel({ setComposeOpen: React.Dispatch>; styleTag: string; activeStyleLabel: string; - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; activeAspectLabel: string; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; activeResolutionLabel: string; + exactSizeLabel?: string | null; activeQualityLabel: string; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; batchCount: number; clearSources: () => void; @@ -54,6 +59,7 @@ export function WindowsComposePanel({ handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; mode: Mode; onPreviewSource: (index: number) => void; onRemoveSource: (index: number) => void; @@ -105,7 +111,9 @@ export function WindowsComposePanel({ activeAspect={activeAspect} aspectOptions={aspectOptions} activeResolution={activeResolution} + exactSizeLabel={exactSizeLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} apiMode={apiMode} availableResolutions={availableResolutions} batchCount={batchCount} @@ -115,6 +123,7 @@ export function WindowsComposePanel({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} onOpenCustomAspectRatioModal={onOpenCustomAspectRatioModal} + onOpenCustomSizeModal={onOpenCustomSizeModal} mode={mode} onPreviewSource={onPreviewSource} onRemoveSource={onRemoveSource} diff --git a/image-studio/frontend/src/components/panel/sizeCapabilities.ts b/image-studio/frontend/src/components/panel/sizeCapabilities.ts index 4707d0811..547eebddf 100644 --- a/image-studio/frontend/src/components/panel/sizeCapabilities.ts +++ b/image-studio/frontend/src/components/panel/sizeCapabilities.ts @@ -8,6 +8,12 @@ import type { APIMode, CustomAspectRatio, RequestPolicy, SizeValue } from "../.. export type BuiltinAspectPreset = "auto" | "1:1" | "3:2" | "2:3" | "16:9" | "9:16" | "7:4" | "4:7"; export type AspectPreset = BuiltinAspectPreset | `custom:${string}`; export type ResolutionPreset = "auto" | "256" | "512" | "1k" | "2k" | "4k"; +export type ExactSizeSelection = { + value: SizeValue; + width: number; + height: number; + label: string; +}; export interface AspectPresetOption { value: AspectPreset; @@ -147,6 +153,8 @@ const CUSTOM_RESOLUTION_REFERENCES: Record<"1k" | "2k" | "4k", { area: number; m }; const CUSTOM_ASPECT_TOLERANCE = 0.035; const BUILTIN_ASPECT_TOLERANCE = 0.08; +export const MIN_EXACT_SIZE = 64; +export const MAX_EXACT_SIZE = 8192; function modelFamily(input: { imageModelID?: string }): ReturnType { return classifyImageModel(input.imageModelID || ""); @@ -174,6 +182,10 @@ export function supportsCustomAspectRatios(input: SizeCapabilityInput): boolean return isFlexibleGPTImageModel(input) || (modelFamily(input) === "other" && input.requestPolicy === "compat"); } +export function supportsPreciseSizeControl(input: SizeCapabilityInput): boolean { + return supportsCustomAspectRatios(input); +} + export function supportsExplicitLargeSizes(input: SizeCapabilityInput): boolean { if (isFlexibleGPTImageModel(input)) return true; return modelFamily(input) === "other" && input.requestPolicy === "compat"; @@ -259,6 +271,37 @@ export function deriveResolutionPreset(size: SizeValue): ResolutionPreset { return best; } +export function formatSizeValue(size: SizeValue): string { + if (size === "auto") return "Auto"; + const parsed = parseSizeValue(size); + return parsed ? `${parsed.width}×${parsed.height}` : size; +} + +export function buildExactSizeValue(width: number, height: number): SizeValue | null { + const safeWidth = normalizeExactSizeDimension(width); + const safeHeight = normalizeExactSizeDimension(height); + if (!safeWidth || !safeHeight) return null; + return `${safeWidth}x${safeHeight}` as SizeValue; +} + +export function deriveExactSizeSelection( + size: SizeValue, + input: SizeCapabilityInput, + customRatios: CustomAspectRatio[] = [], +): ExactSizeSelection | null { + if (size === "auto" || !supportsPreciseSizeControl(input)) return null; + const parsed = parseSizeValue(size); + if (!parsed) return null; + const canonical = sizeValueFromDimensions(parsed.width, parsed.height); + if (!isExactSizeValue(canonical, input, customRatios)) return null; + return { + value: canonical, + width: parsed.width, + height: parsed.height, + label: formatSizeValue(canonical), + }; +} + export function buildSizeSelection( aspect: AspectPreset, resolution: ResolutionPreset, @@ -334,8 +377,14 @@ export function normalizeSizeSelection( if (size === "auto") { return supportsAutomaticSizing(input) ? "auto" : defaultModelSize(input); } - const aspect = deriveAspectPreset(size, customRatios); - const resolution = deriveResolutionPreset(size); + const parsed = parseSizeValue(size); + if (!parsed) return defaultModelSize(input); + const canonical = sizeValueFromDimensions(parsed.width, parsed.height); + if (supportsPreciseSizeControl(input) && isExactSizeValue(canonical, input, customRatios)) { + return canonical; + } + const aspect = deriveAspectPreset(canonical, customRatios); + const resolution = deriveResolutionPreset(canonical); return buildSizeSelection(aspect, resolution, input, customRatios); } @@ -382,7 +431,7 @@ function aspectShapeFromRatio(width: number, height: number): { w: number; h: nu }; } -function parseSizeValue(size: SizeValue): { width: number; height: number } | null { +export function parseSizeValue(size: SizeValue): { width: number; height: number } | null { const match = /^(\d+)x(\d+)$/.exec(size); if (!match) return null; const width = Number(match[1]); @@ -432,6 +481,28 @@ function roundDimension(value: number): number { return Math.max(64, Math.round(value / 8) * 8); } +function normalizeExactSizeDimension(value: unknown): number { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num)) return 0; + const rounded = Math.floor(num); + if (rounded < MIN_EXACT_SIZE || rounded > MAX_EXACT_SIZE) return 0; + return rounded; +} + +function sizeValueFromDimensions(width: number, height: number): SizeValue { + return `${width}x${height}` as SizeValue; +} + +function isExactSizeValue( + size: SizeValue, + input: SizeCapabilityInput, + customRatios: CustomAspectRatio[], +): boolean { + const aspect = deriveAspectPreset(size, customRatios); + const resolution = deriveResolutionPreset(size); + return buildSizeSelection(aspect, resolution, input, customRatios) !== size; +} + export function builtInAspectId(width: number, height: number): string { return buildCustomAspectRatioId(width, height); } diff --git a/image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx b/image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx index 85bb46011..6ad7a84d1 100644 --- a/image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx +++ b/image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx @@ -16,11 +16,13 @@ import { ParameterPresetsSection } from "../../components/panel/ParameterPresets import { aspectPresetLabel, availableResolutionPresets, + deriveExactSizeSelection, deriveAspectPreset, deriveResolutionPreset, listAspectPresetOptions, normalizeSizeSelection, supportsCustomAspectRatios, + supportsPreciseSizeControl, } from "../../components/panel/sizeCapabilities"; import { buildAndroidAspectSizeSelection, @@ -38,7 +40,7 @@ export function AndroidPadComposePanel({ userIdentifier, partialImages, batchCount, loopGeneration, sources, currentImage, isRunning, isOptimizingPrompt, apiMode, requestPolicy, baseURL, imageModelID, profiles, customAspectRatios, setField, selectSourceImage, removeSource, clearSources, viewSourceOnCanvas, - openCustomAspectRatioModal, + openCustomAspectRatioModal, openCustomSizeModal, openUpstreamConfig, submit, cancel, optimizePrompt, } = useStudioStore(); const [templateOpen, setTemplateOpen] = useState(false); @@ -59,20 +61,24 @@ export function AndroidPadComposePanel({ const normalizedQuality = normalizeQualitySelection(quality, imageModelID); const qualityOptions = availableQualityOptions(imageModelID); const allowCustomAspectRatios = supportsCustomAspectRatios(capabilityInput); + const allowPreciseSizeControl = supportsPreciseSizeControl(capabilityInput); const activeStyleLabel = STYLE_CHIPS.find((item) => item.id === styleTag)?.label ?? "默认风格"; const aspectOptions = listAspectPresetOptions(capabilityInput, customAspectRatios); - const activeAspect = deriveAspectPreset(normalizedSize, customAspectRatios); - const activeResolution = deriveResolutionPreset(normalizedSize); + const exactSize = deriveExactSizeSelection(normalizedSize, capabilityInput, customAspectRatios); + const derivedAspect = deriveAspectPreset(normalizedSize, customAspectRatios); + const derivedResolution = deriveResolutionPreset(normalizedSize); + const activeAspect = exactSize ? null : derivedAspect; + const activeResolution = exactSize ? null : derivedResolution; const availableResolutions = availableResolutionPresets(capabilityInput); - const activeAspectLabel = aspectPresetLabel(activeAspect, customAspectRatios); - const activeResolutionLabel = activeResolution === "auto" ? "自动" : activeResolution.toUpperCase(); + const activeAspectLabel = exactSize ? "精确尺寸" : aspectPresetLabel(derivedAspect, customAspectRatios); + const activeResolutionLabel = exactSize ? exactSize.label : (derivedResolution === "auto" ? "自动" : derivedResolution.toUpperCase()); const activeQualityLabel = qualityOptions.find((item) => item.value === normalizedQuality)?.label ?? normalizedQuality; const editSourceLabel = sources.length > 0 ? `${sources.length} 张已添加` : currentImage?.savedPath ? "使用当前画板" : "未添加"; const handleAspectSelect = (aspect: typeof activeAspect) => { setField("size", buildAndroidAspectSizeSelection( - aspect, - activeResolution, + aspect ?? derivedAspect, + derivedResolution, capabilityInput, customAspectRatios, )); @@ -80,8 +86,8 @@ export function AndroidPadComposePanel({ const handleResolutionSelect = (resolution: typeof activeResolution) => { setField("size", buildAndroidResolutionSizeSelection( - activeAspect, - resolution, + derivedAspect, + resolution ?? derivedResolution, capabilityInput, customAspectRatios, )); @@ -227,9 +233,11 @@ export function AndroidPadComposePanel({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSize?.label ?? null} activeQualityLabel={activeQualityLabel} activeStyleLabel={activeStyleLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} availableResolutions={availableResolutions} apiMode={apiMode} batchCount={batchCount} @@ -239,6 +247,7 @@ export function AndroidPadComposePanel({ isMediumPad={isMediumPad} needsUpstreamSetup={needsUpstreamSetup} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} + onOpenCustomSizeModal={openCustomSizeModal} onOpenUpstream={() => { vibrateForPlatform(8); openUpstreamConfig("app"); }} quality={normalizedQuality} requestPolicy={requestPolicy} diff --git a/image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx b/image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx index 0809ccdb6..483bada5d 100644 --- a/image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx +++ b/image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx @@ -9,11 +9,13 @@ import { availableQualityOptions, normalizeQualitySelection, STYLE_CHIPS } from import { aspectPresetLabel, availableResolutionPresets, + deriveExactSizeSelection, deriveAspectPreset, deriveResolutionPreset, listAspectPresetOptions, normalizeSizeSelection, supportsCustomAspectRatios, + supportsPreciseSizeControl, } from "../../components/panel/sizeCapabilities"; import { AndroidModeSwitch } from "./AndroidModeSwitch"; import { AndroidPhoneAdvancedSection } from "./AndroidPhoneAdvancedSection"; @@ -40,7 +42,7 @@ export function AndroidPhoneComposePanel({ isRunning, lastPayload, isOptimizingPrompt, apiMode, requestPolicy, baseURL, profiles, imageModelID, customAspectRatios, setField, clearError, pushToast, selectSourceImage, - removeSource, clearSources, viewSourceOnCanvas, openCustomAspectRatioModal, openUpstreamConfig, submit, cancel, retryLast, optimizePrompt, + removeSource, clearSources, viewSourceOnCanvas, openCustomAspectRatioModal, openCustomSizeModal, openUpstreamConfig, submit, cancel, retryLast, optimizePrompt, } = useStudioStore(); const [templateOpen, setTemplateOpen] = useState(false); const [parametersOpen, setParametersOpen] = useState(false); @@ -58,21 +60,25 @@ export function AndroidPhoneComposePanel({ const normalizedQuality = normalizeQualitySelection(quality, imageModelID); const qualityOptions = availableQualityOptions(imageModelID); const allowCustomAspectRatios = supportsCustomAspectRatios(capabilityInput); + const allowPreciseSizeControl = supportsPreciseSizeControl(capabilityInput); const activeStyleLabel = STYLE_CHIPS.find((item) => item.id === styleTag)?.label ?? "默认风格"; const aspectOptions = listAspectPresetOptions(capabilityInput, customAspectRatios); - const activeAspect = deriveAspectPreset(normalizedSize, customAspectRatios); - const activeResolution = deriveResolutionPreset(normalizedSize); + const exactSize = deriveExactSizeSelection(normalizedSize, capabilityInput, customAspectRatios); + const derivedAspect = deriveAspectPreset(normalizedSize, customAspectRatios); + const derivedResolution = deriveResolutionPreset(normalizedSize); + const activeAspect = exactSize ? null : derivedAspect; + const activeResolution = exactSize ? null : derivedResolution; const availableResolutions = availableResolutionPresets(capabilityInput); - const activeAspectLabel = aspectPresetLabel(activeAspect, customAspectRatios); - const activeResolutionLabel = activeResolution === "auto" ? "自动" : activeResolution.toUpperCase(); + const activeAspectLabel = exactSize ? "精确尺寸" : aspectPresetLabel(derivedAspect, customAspectRatios); + const activeResolutionLabel = exactSize ? exactSize.label : (derivedResolution === "auto" ? "自动" : derivedResolution.toUpperCase()); const activeQualityLabel = qualityOptions.find((item) => item.value === normalizedQuality)?.label ?? normalizedQuality; const editSourceLabel = sources.length > 0 ? `${sources.length} 张已添加` : currentImage?.savedPath ? "使用当前画板" : "未添加"; const settingsExpanded = parametersOpen || advancedOpen; const handleAspectSelect = (aspect: typeof activeAspect) => { setField("size", buildAndroidAspectSizeSelection( - aspect, - activeResolution, + aspect ?? derivedAspect, + derivedResolution, capabilityInput, customAspectRatios, )); @@ -80,8 +86,8 @@ export function AndroidPhoneComposePanel({ const handleResolutionSelect = (resolution: typeof activeResolution) => { setField("size", buildAndroidResolutionSizeSelection( - activeAspect, - resolution, + derivedAspect, + resolution ?? derivedResolution, capabilityInput, customAspectRatios, )); @@ -262,15 +268,18 @@ export function AndroidPhoneComposePanel({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSize?.label ?? null} activeQualityLabel={activeQualityLabel} activeStyleLabel={activeStyleLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} availableResolutions={availableResolutions} batchCount={batchCount} handleAspectSelect={handleAspectSelect} handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} + onOpenCustomSizeModal={openCustomSizeModal} apiMode={apiMode} parametersOpen={parametersOpen} quality={normalizedQuality} diff --git a/image-studio/frontend/src/platform/android/parameters/AndroidPadParameterSection.tsx b/image-studio/frontend/src/platform/android/parameters/AndroidPadParameterSection.tsx index 25b700261..45f2acbd8 100644 --- a/image-studio/frontend/src/platform/android/parameters/AndroidPadParameterSection.tsx +++ b/image-studio/frontend/src/platform/android/parameters/AndroidPadParameterSection.tsx @@ -19,9 +19,11 @@ export function AndroidPadParameterSection({ aspectOptions, activeResolution, activeResolutionLabel, + exactSizeLabel, activeQualityLabel, activeStyleLabel, allowCustomAspectRatios, + allowPreciseSizeControl, availableResolutions, apiMode, batchCount, @@ -31,20 +33,23 @@ export function AndroidPadParameterSection({ isMediumPad, needsUpstreamSetup, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, onOpenUpstream, quality, requestPolicy, setField, styleTag, }: { - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; activeAspectLabel: string; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; activeResolutionLabel: string; + exactSizeLabel?: string | null; activeQualityLabel: string; activeStyleLabel: string; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; apiMode: "responses" | "images"; batchCount: number; @@ -54,6 +59,7 @@ export function AndroidPadParameterSection({ isMediumPad: boolean; needsUpstreamSetup: boolean; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; onOpenUpstream: () => void; quality: string; requestPolicy: "openai" | "compat"; @@ -114,9 +120,11 @@ export function AndroidPadParameterSection({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSizeLabel} activeQualityLabel={activeQualityLabel} activeStyleLabel={activeStyleLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} availableResolutions={availableResolutions} apiMode={apiMode} batchCount={batchCount} @@ -124,6 +132,7 @@ export function AndroidPadParameterSection({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} onOpenCustomAspectRatioModal={onOpenCustomAspectRatioModal} + onOpenCustomSizeModal={onOpenCustomSizeModal} quality={quality} requestPolicy={requestPolicy} setField={setField} diff --git a/image-studio/frontend/src/platform/android/parameters/AndroidParameterEditor.tsx b/image-studio/frontend/src/platform/android/parameters/AndroidParameterEditor.tsx index 34afc6ad7..109378025 100644 --- a/image-studio/frontend/src/platform/android/parameters/AndroidParameterEditor.tsx +++ b/image-studio/frontend/src/platform/android/parameters/AndroidParameterEditor.tsx @@ -25,9 +25,11 @@ export function AndroidParameterEditor({ aspectOptions, activeResolution, activeResolutionLabel, + exactSizeLabel, activeQualityLabel, activeStyleLabel, allowCustomAspectRatios, + allowPreciseSizeControl, availableResolutions, apiMode, batchCount, @@ -35,19 +37,22 @@ export function AndroidParameterEditor({ handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, quality, requestPolicy, setField, styleTag, }: { - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; activeAspectLabel: string; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; activeResolutionLabel: string; + exactSizeLabel?: string | null; activeQualityLabel: string; activeStyleLabel: string; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; apiMode: "responses" | "images"; batchCount: number; @@ -55,6 +60,7 @@ export function AndroidParameterEditor({ handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; quality: string; requestPolicy: "openai" | "compat"; setField: (key: "quality" | "styleTag" | "batchCount", value: any) => void; @@ -102,11 +108,29 @@ export function AndroidParameterEditor({ availableResolutions.includes(item.value))} onChange={handleResolutionSelect} note={resolutionHint} /> + {allowPreciseSizeControl ? ( + + {exactSizeLabel ? "修改" : "设置"} + + )} + > +

+ {exactSizeLabel + ? `当前精确尺寸 ${exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。` + : "需要精确像素时可直接输入宽高,自定义 size 会原样下发给上游。"} +

+ + ) : null} + void; - value: AspectPreset; + value: AspectPreset | null; onChange: (value: AspectPreset) => void; }) { return ( @@ -258,6 +258,7 @@ export function AndroidAspectGrid({ } export function AndroidDiscreteSlider({ + displayValue, label, note, onChange, @@ -265,14 +266,17 @@ export function AndroidDiscreteSlider({ value, valueSuffix = "", }: { + displayValue?: string; label: string; note?: string; onChange: (value: T) => void; options: Array<{ value: T; label: string }>; - value: T; + value: T | null; valueSuffix?: string; }) { - const activeIndex = Math.max(0, options.findIndex((item) => item.value === value)); + const matchedIndex = options.findIndex((item) => item.value === value); + const hasActiveValue = matchedIndex >= 0; + const activeIndex = hasActiveValue ? matchedIndex : 0; const denominator = Math.max(1, options.length - 1); const progress = `${(activeIndex / denominator) * 100}%`; const activeOption = options[activeIndex] ?? options[0]; @@ -289,7 +293,7 @@ export function AndroidDiscreteSlider({
- {activeOption?.label}{valueSuffix} + {displayValue ?? activeOption?.label}{valueSuffix}
({ value={activeIndex} disabled={disabled} aria-label={label} - aria-valuetext={`${activeOption?.label ?? ""}${valueSuffix}`} + aria-valuetext={`${displayValue ?? activeOption?.label ?? ""}${valueSuffix}`} onChange={(event) => commit(Number(event.currentTarget.value))} />
@@ -316,9 +320,9 @@ export function AndroidDiscreteSlider({ diff --git a/image-studio/frontend/src/platform/android/parameters/AndroidPhoneParameterSection.tsx b/image-studio/frontend/src/platform/android/parameters/AndroidPhoneParameterSection.tsx index bd6e60fd1..d47a1aa4d 100644 --- a/image-studio/frontend/src/platform/android/parameters/AndroidPhoneParameterSection.tsx +++ b/image-studio/frontend/src/platform/android/parameters/AndroidPhoneParameterSection.tsx @@ -18,9 +18,11 @@ export function AndroidPhoneParameterSection({ aspectOptions, activeResolution, activeResolutionLabel, + exactSizeLabel, activeQualityLabel, activeStyleLabel, allowCustomAspectRatios, + allowPreciseSizeControl, availableResolutions, apiMode, batchCount, @@ -28,6 +30,7 @@ export function AndroidPhoneParameterSection({ handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, + onOpenCustomSizeModal, parametersOpen, quality, requestPolicy, @@ -35,14 +38,16 @@ export function AndroidPhoneParameterSection({ setParametersOpen, styleTag, }: { - activeAspect: AspectPreset; + activeAspect: AspectPreset | null; activeAspectLabel: string; aspectOptions: AspectPresetOption[]; - activeResolution: ResolutionPreset; + activeResolution: ResolutionPreset | null; activeResolutionLabel: string; + exactSizeLabel?: string | null; activeQualityLabel: string; activeStyleLabel: string; allowCustomAspectRatios: boolean; + allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; apiMode: "responses" | "images"; batchCount: number; @@ -50,6 +55,7 @@ export function AndroidPhoneParameterSection({ handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; + onOpenCustomSizeModal: () => void; parametersOpen: boolean; quality: string; requestPolicy: "openai" | "compat"; @@ -90,9 +96,11 @@ export function AndroidPhoneParameterSection({ aspectOptions={aspectOptions} activeResolution={activeResolution} activeResolutionLabel={activeResolutionLabel} + exactSizeLabel={exactSizeLabel} activeQualityLabel={activeQualityLabel} activeStyleLabel={activeStyleLabel} allowCustomAspectRatios={allowCustomAspectRatios} + allowPreciseSizeControl={allowPreciseSizeControl} availableResolutions={availableResolutions} apiMode={apiMode} batchCount={batchCount} @@ -100,6 +108,7 @@ export function AndroidPhoneParameterSection({ handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} onOpenCustomAspectRatioModal={onOpenCustomAspectRatioModal} + onOpenCustomSizeModal={onOpenCustomSizeModal} quality={quality} requestPolicy={requestPolicy} setField={setField} diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index 41832dd18..eb68b0041 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -103,11 +103,14 @@ import { } from "./workspaceRuntime"; import { buildAspectSizeSelection, + buildExactSizeValue, buildCustomAspectValue, deriveAspectPreset, deriveResolutionPreset, + formatSizeValue, isBuiltInAspectRatio, normalizeSizeSelection, + supportsPreciseSizeControl, } from "../components/panel/sizeCapabilities"; import { normalizeQualitySelection } from "../components/panel/panelOptions"; import { buildMacWorkspacePreview, readPreviewScenario } from "../app/dev/previewData"; @@ -594,6 +597,28 @@ export const useStudioStore = create((set, get) => ({ set(patch); get().pushToast(`已删除比例 ${removed.label}`, "success"); }, + customSizeModalOpen: false, + openCustomSizeModal: () => set({ customSizeModalOpen: true }), + closeCustomSizeModal: () => set({ customSizeModalOpen: false }), + applyCustomSize: (width, height) => { + const state = get(); + if (!supportsPreciseSizeControl({ + apiMode: state.apiMode, + requestPolicy: state.requestPolicy, + imageModelID: state.imageModelID, + })) { + state.pushToast("当前模型链路不支持精确尺寸自定义", "warn"); + return false; + } + const nextSize = buildExactSizeValue(width, height); + if (!nextSize) { + state.pushToast("请输入 64 到 8192 之间的整数尺寸", "warn"); + return false; + } + set({ size: nextSize, customSizeModalOpen: false }); + get().pushToast(`已应用精确尺寸 ${formatSizeValue(nextSize)}`, "success"); + return true; + }, settingsOpen: false, openSettings: () => set({ settingsOpen: true, upstreamModalOpen: false }), closeSettings: () => set({ settingsOpen: false }), @@ -958,7 +983,7 @@ export const useStudioStore = create((set, get) => ({ apiMode: s.apiMode, requestPolicy: s.requestPolicy, imageModelID: s.imageModelID, - }); + }, s.customAspectRatios); const resolvedQuality = normalizeQualitySelection(s.quality, s.imageModelID); const basePayload: backend.GenerateOptions = { @@ -1175,6 +1200,7 @@ export const useStudioStore = create((set, get) => ({ isTestingKey: false, isOptimizingPrompt: false, customAspectRatioModalOpen: false, + customSizeModalOpen: false, upstreamModalOpen: false, upstreamReturnTarget: "app", starPromptOpen: false, @@ -1454,6 +1480,7 @@ export const useStudioStore = create((set, get) => ({ // Android 走首页 hero 引导,不用启动即弹设置;桌面仍保留首次引导。 settingsOpen: shouldAutoOpenSettings, customAspectRatioModalOpen: false, + customSizeModalOpen: false, upstreamModalOpen: false, upstreamReturnTarget: shouldAutoOpenSettings ? "settings" : "app", savePromptItem: null, diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts index 2c74c6a71..0a2286867 100644 --- a/image-studio/frontend/src/state/studioStore.types.ts +++ b/image-studio/frontend/src/state/studioStore.types.ts @@ -238,6 +238,10 @@ export interface StudioState { closeCustomAspectRatioModal: () => void; addCustomAspectRatio: (width: number, height: number) => boolean; deleteCustomAspectRatio: (id: string) => void; + customSizeModalOpen: boolean; + openCustomSizeModal: () => void; + closeCustomSizeModal: () => void; + applyCustomSize: (width: number, height: number) => boolean; settingsOpen: boolean; openSettings: () => void; closeSettings: () => void; diff --git a/image-studio/frontend/src/styles/fluent/_windows-controls.css b/image-studio/frontend/src/styles/fluent/_windows-controls.css index c3c3b7881..0bafd39e0 100644 --- a/image-studio/frontend/src/styles/fluent/_windows-controls.css +++ b/image-studio/frontend/src/styles/fluent/_windows-controls.css @@ -64,6 +64,76 @@ html[data-platform="windows"][data-ui-family="fluent"] .api-mode-chip.active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); } +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-grid { + padding: 4px; + border: 1px solid color-mix(in srgb, var(--border) 92%, white 8%); + border-radius: 10px; + background: color-mix(in srgb, var(--surface-2) 90%, transparent); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22); +} + +html.dark[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-grid { + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button { + min-height: 44px; + border-style: solid; + border-width: 1px; + border-color: color-mix(in srgb, var(--border) 90%, black 10%); + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.28), + 0 1px 1px rgb(0 0 0 / 0.03); +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button:hover { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: color-mix(in srgb, var(--surface) 40%, var(--accent) 8%); +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button[data-active="true"], +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--accent) 54%, white 10%); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 18%, white 82%) 0%, + color-mix(in srgb, var(--accent) 12%, var(--surface)) 100% + ); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, white 8%), + 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent); +} + +html.dark[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button[data-active="true"], +html.dark[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-button[aria-pressed="true"] { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 24%, rgb(255 255 255 / 0.14)) 0%, + color-mix(in srgb, var(--accent) 16%, var(--surface)) 100% + ); +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-content { + display: inline-flex; + min-width: 0; + align-items: baseline; + gap: 4px; + line-height: 1; +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-value { + font-size: 15px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +html[data-platform="windows"][data-ui-family="fluent"] .windows-batch-count-unit { + font-size: 10px; + letter-spacing: 0.04em; + opacity: 0.76; +} + html[data-platform="windows"][data-ui-family="fluent"] .liquid-primary-button, html[data-platform="windows"][data-ui-family="fluent"] .btn { min-height: 32px; diff --git a/image-studio/frontend/test/sizeCapabilities.test.mjs b/image-studio/frontend/test/sizeCapabilities.test.mjs index 29933737b..c6f40a081 100644 --- a/image-studio/frontend/test/sizeCapabilities.test.mjs +++ b/image-studio/frontend/test/sizeCapabilities.test.mjs @@ -96,6 +96,21 @@ test("non-gpt-image openai-standard paths stay on base resolution presets", () = }), "1536x864"); }); +test("precise custom sizes stay untouched when the active model path supports them", () => { + const input = { + apiMode: "responses", + requestPolicy: "openai", + imageModelID: "gpt-image-2", + }; + assert.equal(caps.normalizeSizeSelection("2000x1000", input), "2000x1000"); + assert.deepEqual(caps.deriveExactSizeSelection("2000x1000", input), { + value: "2000x1000", + width: 2000, + height: 1000, + label: "2000×1000", + }); +}); + test("compat mode can keep large resolution presets available for compatible relays", () => { const values = caps.availableResolutionPresets({ apiMode: "responses", @@ -171,4 +186,6 @@ test("custom aspect ratios can build sizes and round-trip back to the active cus assert.equal(size, "1496x1864"); assert.equal(caps.deriveAspectPreset(size, customRatios), value); assert.equal(caps.deriveResolutionPreset(size), "2k"); + assert.equal(caps.normalizeSizeSelection(size, input, customRatios), size); + assert.equal(caps.deriveExactSizeSelection(size, input, customRatios), null); }); diff --git a/scripts/sign-windows-binary.ps1 b/scripts/sign-windows-binary.ps1 new file mode 100644 index 000000000..070c54dd1 --- /dev/null +++ b/scripts/sign-windows-binary.ps1 @@ -0,0 +1,84 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Path +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-SignToolPath { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $kitsRoot = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin" + if (-not (Test-Path -LiteralPath $kitsRoot)) { + throw "signtool.exe not found in PATH and Windows SDK bin directory is missing." + } + + $candidate = Get-ChildItem -Path $kitsRoot -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } | + Sort-Object FullName -Descending | + Select-Object -First 1 + + if (-not $candidate) { + throw "signtool.exe not found under Windows SDK bin directory." + } + + return $candidate.FullName +} + +$resolvedPath = (Resolve-Path -LiteralPath $Path).Path +$certBase64 = $env:IMAGE_STUDIO_WINDOWS_CERT_BASE64 +$certPassword = $env:IMAGE_STUDIO_WINDOWS_CERT_PASSWORD +$timestampUrl = if ([string]::IsNullOrWhiteSpace($env:IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL)) { + "http://timestamp.acs.microsoft.com" +} else { + $env:IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL.Trim() +} + +if ([string]::IsNullOrWhiteSpace($certBase64)) { + throw "Missing IMAGE_STUDIO_WINDOWS_CERT_BASE64." +} + +if ([string]::IsNullOrWhiteSpace($certPassword)) { + throw "Missing IMAGE_STUDIO_WINDOWS_CERT_PASSWORD." +} + +$tempPfx = Join-Path $env:RUNNER_TEMP ("image-studio-codesign-" + [guid]::NewGuid().ToString("N") + ".pfx") +$signtool = Get-SignToolPath + +try { + [IO.File]::WriteAllBytes($tempPfx, [Convert]::FromBase64String($certBase64)) + + & $signtool sign ` + /fd SHA256 ` + /td SHA256 ` + /tr $timestampUrl ` + /f $tempPfx ` + /p $certPassword ` + $resolvedPath + if ($LASTEXITCODE -ne 0) { + throw "signtool sign failed with exit code $LASTEXITCODE." + } + + & $signtool verify /pa /all $resolvedPath + if ($LASTEXITCODE -ne 0) { + throw "signtool verify failed with exit code $LASTEXITCODE." + } + + $signature = Get-AuthenticodeSignature -FilePath $resolvedPath + if ($signature.Status -ne "Valid") { + throw "Authenticode verification returned status '$($signature.Status)'." + } + + $subject = $signature.SignerCertificate.Subject + Write-Host "Signed $resolvedPath" + Write-Host "Signer: $subject" + Write-Host "Timestamp: $timestampUrl" +} finally { + if (Test-Path -LiteralPath $tempPfx) { + Remove-Item -LiteralPath $tempPfx -Force + } +}