diff --git a/README.md b/README.md index 6151e10..16432ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.61**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.62**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,15 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.61 主要特性 +## 0.1.62 主要特性 + +- **简洁模式可直接接收分享**:主页新增“收到分享”区块,支持刷新并直接打开同事分享的成品;新用户无需先切换到专业模式。 + +- **局部改图保留蒙版外原图**:服务端按 OpenAI 蒙版语义合成最终图片,透明区域采用编辑结果;PNG 成品的非透明区域逐像素保留输入原图。 + +- **避免重复叠加官方 LOGO**:发现标准位置已有官方透明 PNG 时直接保留原文件,不重复贴入、不重新编码;新贴入时仍默认锁定左上标准位置,只在安全区复杂度明显改善时移动。 + +- **完整展示真实输出比例**:结果预览与候选缩略图使用完整包含布局,纵向或横向图片不再被工作区网格裁切。 - **正常页面刷新不再误限流**:业务 API 仍受每分钟总配额保护,5 秒 burst 只约束写请求;生成完成后的统计刷新、工作区重载和作品列表读取不会互相挤占短时写入配额。 @@ -115,10 +123,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.61 . +docker build -t minorli/picgen:0.1.62 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.61 + minorli/picgen:0.1.62 ``` 或: @@ -133,10 +141,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.61`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.62`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.61 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.62 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -249,7 +257,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.61 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.62 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index b355c5e..55989e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.61 + image: minorli/picgen:0.1.62 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index c19879d..756aa19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.61" +version = "0.1.62" description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh index a1cffde..c9a86cd 100755 --- a/scripts/docker-build-push.sh +++ b/scripts/docker-build-push.sh @@ -2,7 +2,7 @@ set -euo pipefail IMAGE="${IMAGE:-minorli/picgen}" -VERSION="${VERSION:-0.1.61}" +VERSION="${VERSION:-0.1.62}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/routes.py b/src/picgen/routes.py index c1e83d6..7208c51 100644 --- a/src/picgen/routes.py +++ b/src/picgen/routes.py @@ -113,6 +113,7 @@ ) from .storage import ( MAX_EXACT_IMAGE_PIXELS, + composite_masked_edit_image, detect_image_mime, extension_for_mime, resolve_storage_path, @@ -1100,6 +1101,45 @@ def _prepare_mask(part: FilePayload, max_image_bytes: int) -> dict[str, Any]: return file_info +def _masked_edit_image_transform( + source_part: dict[str, Any] | None, + mask_part: dict[str, Any] | None, +) -> Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None: + if not source_part or not mask_part: + return None + source_bytes = bytes(source_part.get("data") or b"") + mask_bytes = bytes(mask_part.get("data") or b"") + if not source_bytes or not mask_bytes: + return None + + def transform( + generated_bytes: bytes, + generated_mime: str, + candidate_index: int, + ) -> tuple[bytes, str, dict[str, object]]: + try: + return composite_masked_edit_image( + source_image_bytes=source_bytes, + mask_image_bytes=mask_bytes, + generated_image_bytes=generated_bytes, + generated_image_mime=generated_mime, + ) + except (OSError, ValueError) as exc: + log_event( + logger, + logging.WARNING, + "mask_composite_failed", + candidate_index=candidate_index, + error=type(exc).__name__, + ) + return generated_bytes, generated_mime, { + "mask_composited": False, + "mask_composite_error": type(exc).__name__, + } + + return transform + + def _itinerary_style_reference_part(settings: Settings) -> dict[str, Any] | None: candidates = ( settings.static_dir / "itinerary-style-reference.png", @@ -4855,6 +4895,7 @@ async def _run_edit_upstream() -> dict[str, Any]: "sample_count": parsed.sample_count, **({"user": user.public_dict()} if user else {}), }, + image_transform=_masked_edit_image_transform(image_parts[0], mask_part), ) @@ -4960,6 +5001,7 @@ async def handle_responses_image( for key in ("quality", "background", "output_format", "output_compression", "moderation"): if key in image_options: tool[key] = image_options[key] + mask_info: dict[str, Any] | None = None if parsed.mask is not None: mask_info = _validate_image_size(parsed.mask, settings.max_image_bytes) mask_b64 = base64.b64encode(mask_info["data"]).decode("ascii") @@ -5058,6 +5100,10 @@ async def _run_responses_upstream() -> dict[str, Any]: "sample_count": parsed.sample_count, **({"user": user.public_dict()} if user else {}), }, + image_transform=_masked_edit_image_transform( + image_parts[0] if image_parts else None, + mask_info, + ), ) @@ -5068,6 +5114,7 @@ async def _finalize_image_response( client: UpstreamClient, save_context: dict[str, Any], extra: dict[str, Any], + image_transform: Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None = None, ) -> dict[str, Any]: fetch_remote_sync = _make_remote_fetcher(client) saved = await anyio.to_thread.run_sync( @@ -5078,6 +5125,7 @@ async def _finalize_image_response( user_agent=settings.upstream_user_agent, save_context=save_context, fetch_remote=fetch_remote_sync, + image_transform=image_transform, ) ) # An empty upstream response (no candidates) must be a clear error, not a blank @@ -5196,6 +5244,7 @@ async def _finalize_with_size_retry( save_context: dict[str, Any], extra: dict[str, Any], sample_count: int, + image_transform: Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None = None, ) -> dict[str, Any]: """Retry the (paid) upstream call when it "lazily" returns a smaller size. @@ -5225,6 +5274,7 @@ async def _finalize_with_size_retry( client=client, save_context=save_context, extra=extra, + image_transform=image_transform, ) except APIError as exc: if best is not None: diff --git a/src/picgen/storage.py b/src/picgen/storage.py index e744795..568ce5f 100644 --- a/src/picgen/storage.py +++ b/src/picgen/storage.py @@ -265,6 +265,68 @@ def resize_image_to_exact_size( ) +def composite_masked_edit_image( + *, + source_image_bytes: bytes, + mask_image_bytes: bytes, + generated_image_bytes: bytes, + generated_image_mime: str, +) -> tuple[bytes, str, dict[str, object]]: + """Keep source pixels wherever the OpenAI-style mask is opaque.""" + + normalized_mime = generated_image_mime.lower().split(";", 1)[0].strip() + output_format = _MIME_TO_PIL_FORMAT.get(normalized_mime) + if output_format is None: + raise ValueError(f"unsupported image mime for masked edit: {generated_image_mime}") + + with ( + Image.open(BytesIO(source_image_bytes)) as source_file, + Image.open(BytesIO(mask_image_bytes)) as mask_file, + Image.open(BytesIO(generated_image_bytes)) as generated_file, + ): + source = ImageOps.exif_transpose(source_file).convert("RGBA") + mask = ImageOps.exif_transpose(mask_file) + generated = ImageOps.exif_transpose(generated_file).convert("RGBA") + if source.width * source.height > MAX_EXACT_IMAGE_PIXELS: + raise ValueError(f"source image exceeds {MAX_EXACT_IMAGE_PIXELS} pixels") + if mask.size != source.size: + raise ValueError("mask dimensions must match source image dimensions") + if "A" not in mask.getbands(): + raise ValueError("mask image must contain an alpha channel") + if generated.size != source.size: + generated = ImageOps.fit( + generated, + source.size, + method=Image.Resampling.LANCZOS, + centering=(0.5, 0.5), + ) + + edit_alpha = ImageOps.invert(mask.getchannel("A")) + composed = Image.composite(generated, source, edit_alpha) + if output_format == "JPEG": + composed = composed.convert("RGB") + + output = BytesIO() + if output_format == "PNG": + composed.save(output, format=output_format, compress_level=6) + elif output_format == "JPEG": + composed.save(output, format=output_format, quality=95, optimize=True) + elif output_format == "WEBP": + composed.save(output, format=output_format, quality=95) + else: # pragma: no cover - guarded by _MIME_TO_PIL_FORMAT + composed.save(output, format=output_format) + + return ( + output.getvalue(), + normalized_mime, + { + "mask_composited": True, + "mask_preserve_mode": "inverse_alpha", + "mask_source_size": f"{source.width}x{source.height}", + }, + ) + + def storage_url_for_path(data_dir: Path, file_path: Path) -> str: # Relative URL (no leading slash) so it resolves correctly both when PicGen is # served at the site root and when it is mounted under a sub-path (e.g. /picgen/). diff --git a/src/picgen/upstream/payload.py b/src/picgen/upstream/payload.py index d6853d8..af39608 100644 --- a/src/picgen/upstream/payload.py +++ b/src/picgen/upstream/payload.py @@ -5,6 +5,7 @@ import json import logging import re +from collections.abc import Callable from datetime import UTC, datetime from http import HTTPStatus from pathlib import Path @@ -23,6 +24,8 @@ logger = get_logger("picgen.upstream.payload") +ImageTransform = Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] + _IMAGE_OPTION_KEYS = ("quality", "background", "output_format", "output_compression", "moderation") _RAW_IMAGE_KEYS = frozenset({"b64_json", "result", "partial_image_b64", "image_b64"}) _RAW_IMAGE_URL_KEYS = frozenset({"url", "image_url"}) @@ -283,6 +286,7 @@ def _image_item_payload( user_agent: str, save_context: dict[str, Any], fetch_remote: Any | None = None, + image_transform: ImageTransform | None = None, index: int = 0, ) -> dict[str, Any]: base64_image = item.get("b64_json") @@ -325,6 +329,17 @@ def _image_item_payload( image_mime, save_context, ) + transform_metadata: dict[str, object] = {} + if image_transform is not None: + image_bytes, image_mime, transform_metadata = image_transform( + image_bytes, + image_mime, + index, + ) + image_data_url = ( + f"data:{image_mime};base64," + f"{base64.b64encode(image_bytes).decode('ascii')}" + ) saved_payload = save_output_image( data_dir=data_dir, outputs_dir=outputs_dir, @@ -340,9 +355,11 @@ def _image_item_payload( "upstream_image_url": image_url, "saved_image_mime": image_mime, **normalization_metadata, + **transform_metadata, }, ) saved_payload.update(normalization_metadata) + saved_payload.update(transform_metadata) # When the image is persisted, the browser loads it from the saved file URL # (same-origin), so we drop the multi-megabyte inline data URL to keep @@ -370,6 +387,7 @@ def prepare_image_payload( user_agent: str, save_context: dict[str, Any], fetch_remote: Any | None = None, + image_transform: ImageTransform | None = None, ) -> dict[str, Any]: """Build the response payload sent back to the browser and persist the image. @@ -397,6 +415,7 @@ def prepare_image_payload( user_agent=user_agent, save_context=save_context, fetch_remote=fetch_remote, + image_transform=image_transform, index=index, ) except Exception as exc: diff --git a/static/app.js b/static/app.js index ac24a7d..f4eff6a 100644 --- a/static/app.js +++ b/static/app.js @@ -1,11 +1,15 @@ -import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.61" +import { + calculateLogoPlacementScore, + calculateOfficialLogoPixelMatch, + chooseLogoPlacement, +} from "./logo-placement.mjs?v=0.1.62" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.61" +} from "./responses-settings.mjs?v=0.1.62" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" @@ -37,6 +41,8 @@ const COMPANY_LOGO_MAX_WIDTH = 220 const COMPANY_LOGO_MARGIN_RATIO = 0.04 const COMPANY_LOGO_MIN_MARGIN = 16 const COMPANY_LOGO_MAX_MARGIN = 42 +const COMPANY_LOGO_EXISTING_MATCH_THRESHOLD = 0.9 +const COMPANY_LOGO_EXISTING_MATCH_MIN_PIXELS = 128 const COMPANY_LOGO_LAYOUT_PROMPT = [ "LOGO 布局要求:请在画面左上角为 6 人游 LOGO 预留自然干净的留白,避免人物、文字、建筑边缘、产品主体或高对比元素与 LOGO 位置发生重合。", "最终 LOGO 将使用官方透明 PNG 原样贴入,图标、字体、颜色和比例均不得改动。", @@ -424,6 +430,10 @@ const refs = { simpleFirstRunChecklist: document.querySelector("#simpleFirstRunChecklist"), simpleChecklistCloseButton: document.querySelector("#simpleChecklistCloseButton"), simpleChecklistSteps: Array.from(document.querySelectorAll("[data-simple-checklist-step]")), + simpleSharedResultsSection: document.querySelector("#simpleSharedResultsSection"), + simpleSharedResultsList: document.querySelector("#simpleSharedResultsList"), + simpleSharedResultsEmpty: document.querySelector("#simpleSharedResultsEmpty"), + simpleRefreshSharedResultsButton: document.querySelector("#simpleRefreshSharedResultsButton"), simpleGallerySection: document.querySelector("#simpleGallerySection"), simpleGalleryRefreshButton: document.querySelector("#simpleGalleryRefreshButton"), simpleGalleryList: document.querySelector("#simpleGalleryList"), @@ -995,6 +1005,7 @@ function applyAnonymousShellChrome() { refs.changePasswordButton?.classList.add("hidden") refs.logoutButton?.classList.add("hidden") refs.teamChatButton?.classList.add("hidden") + refs.simpleSharedResultsSection?.classList.add("hidden") if (refs.openProfileButton) { refs.openProfileButton.disabled = true } @@ -1757,8 +1768,9 @@ async function deleteAdminUser(userId) { function updateFeedbackPanelVisibility() { const hasResult = Boolean(state.resultPreview?.src) - refs.resultFeedbackPanel?.classList.toggle("hidden", !hasResult) - if (!hasResult) { + const canSubmitFeedback = hasResult && !currentResultIsShared() + refs.resultFeedbackPanel?.classList.toggle("hidden", !canSubmitFeedback) + if (!canSubmitFeedback) { refs.feedbackReasonPanel?.classList.add("hidden") refs.shareResultPanel?.classList.add("hidden") } @@ -3089,15 +3101,16 @@ function closeSimpleMoreActions() { } function updateSimpleMoreActionStates() { + const sharedResult = currentResultIsShared() const actions = Object.fromEntries( Array.from(refs.simpleMoreActionsMenu?.querySelectorAll("[data-simple-result-action]") || []) .map((button) => [button.dataset.simpleResultAction, button]), ) if (actions.original) actions.original.disabled = refs.downloadOriginalButton?.disabled !== false - if (actions.versions) actions.versions.disabled = refs.showVersionsButton?.disabled !== false - if (actions.group) actions.group.disabled = refs.saveGroupAssetButton?.disabled !== false + if (actions.versions) actions.versions.disabled = sharedResult || refs.showVersionsButton?.disabled !== false + if (actions.group) actions.group.disabled = sharedResult || refs.saveGroupAssetButton?.disabled !== false if (actions.copy) actions.copy.disabled = !state.lastResultPrompt - if (actions.organize) actions.organize.disabled = !selectedGeneratedImageId() + if (actions.organize) actions.organize.disabled = sharedResult || !selectedGeneratedImageId() } function updateResultActionLabelsForMode() { @@ -3132,7 +3145,7 @@ function updateSimpleResultSurface() { const showEmpty = state.uiMode === "simple" && !hasResult && !state.isBusy && !hasFailure refs.simpleResultEmpty?.classList.toggle("hidden", !showEmpty) if (refs.simpleShareResultButton) { - refs.simpleShareResultButton.disabled = !selectedGeneratedImageId() || state.isBusy + refs.simpleShareResultButton.disabled = !selectedGeneratedImageId() || state.isBusy || currentResultIsShared() } updateSimpleMoreActionStates() updateResultActionLabelsForMode() @@ -3865,6 +3878,10 @@ function serverShareableImageUrl(candidate = {}) { } async function saveCurrentResultToGroupAssets() { + if (currentResultIsShared()) { + setStatusMessage("收到的分享需要先编辑生成自己的作品,才能存入部门群。") + return + } const generatedImageId = selectedGeneratedImageId() if (!generatedImageId) { setStatusMessage("这张图还没有服务端记录,暂时不能存入部门群。") @@ -4004,7 +4021,7 @@ async function refreshShareRecipients() { } function showSharePanel() { - if (!state.resultPreview?.src) { + if (!state.resultPreview?.src || currentResultIsShared()) { return } refs.shareResultPanel?.classList.remove("hidden") @@ -4017,7 +4034,7 @@ function showSharePanel() { } async function submitShareResult() { - if (!state.resultPreview?.src) { + if (!state.resultPreview?.src || currentResultIsShared()) { setError("当前没有可分享的结果图。") return } @@ -4057,24 +4074,17 @@ async function submitShareResult() { } } -function renderSharedResults(shares) { - state.sharedResults = Array.isArray(shares) ? shares : [] - if (!refs.sharedResultsList || !refs.sharedResultsEmpty) { - return - } - refs.sharedResultsEmpty.classList.toggle("hidden", state.sharedResults.length > 0) - if (!state.sharedResults.length) { - refs.sharedResultsList.innerHTML = "" - return - } - refs.sharedResultsList.innerHTML = state.sharedResults.slice(0, 10).map((share) => { +function sharedResultItemsMarkup(shares, { simple = false } = {}) { + const itemClass = simple ? "simple-shared-result-item" : "shared-result-item" + const copyClass = simple ? "simple-shared-result-copy" : "shared-result-copy" + return shares.slice(0, 10).map((share) => { const imageUrl = share.saved_image_url || "" const sender = share.sender_username || "用户" const prompt = share.prompt || "未附提示词" return ` - + +
    +
  1. 1选一个场景
  2. +
  3. 2填好标题点生成
  4. +
  5. 3下载成品或分享
  6. +
+ +
@@ -225,6 +239,20 @@

填写内容

+ + -
- PicGen Console v0.1.61 + PicGen Console v0.1.62 本地代理保存结果到 data/outputs
@@ -1356,6 +1371,6 @@

图片预览

- + diff --git a/static/logo-placement.mjs b/static/logo-placement.mjs index 358d192..7a488a3 100644 --- a/static/logo-placement.mjs +++ b/static/logo-placement.mjs @@ -1,5 +1,7 @@ const MIN_ABSOLUTE_SCORE_IMPROVEMENT = 2 const MIN_RELATIVE_SCORE_IMPROVEMENT = 0.25 +const OFFICIAL_LOGO_MATCH_MIN_ALPHA = 245 +const OFFICIAL_LOGO_MATCH_MAX_CHANNEL_DELTA = 24 const SAFETY_PADDING = Object.freeze({ left: 0.12, @@ -29,6 +31,37 @@ function pixelLuminance(data, index) { return 0.2126 * data[index] + 0.7152 * data[index + 1] + 0.0722 * data[index + 2] } +export function calculateOfficialLogoPixelMatch(baseImageData, logoImageData) { + const base = baseImageData?.data + const logo = logoImageData?.data + if (!base || !logo || base.length !== logo.length || base.length % 4 !== 0) { + return { score: 0, matchedPixels: 0, comparedPixels: 0 } + } + + let matchedPixels = 0 + let comparedPixels = 0 + for (let index = 0; index < logo.length; index += 4) { + if (logo[index + 3] < OFFICIAL_LOGO_MATCH_MIN_ALPHA) { + continue + } + comparedPixels += 1 + const largestChannelDelta = Math.max( + Math.abs(base[index] - logo[index]), + Math.abs(base[index + 1] - logo[index + 1]), + Math.abs(base[index + 2] - logo[index + 2]), + ) + if (largestChannelDelta <= OFFICIAL_LOGO_MATCH_MAX_CHANNEL_DELTA) { + matchedPixels += 1 + } + } + + return { + score: comparedPixels ? matchedPixels / comparedPixels : 0, + matchedPixels, + comparedPixels, + } +} + function analyzeRegion(ctx, region) { const { x, y, width, height } = normalizeRegion(ctx, region) const { data } = ctx.getImageData(x, y, width, height) diff --git a/static/styles.css b/static/styles.css index 24cd1cd..066300a 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1410,7 +1410,7 @@ select:focus, display: block; width: 100%; aspect-ratio: 1; - object-fit: cover; + object-fit: contain; border-radius: 5px; background: #eef2f7; } @@ -1967,6 +1967,8 @@ select:focus, display: none; width: 100%; height: 100%; + min-width: 0; + min-height: 0; max-width: 100%; max-height: 100%; object-fit: contain; @@ -2275,6 +2277,14 @@ select:focus, font-weight: 700; } +.gallery-favorite-toggle input { + width: 16px; + height: 16px; + min-height: 0; + padding: 0; + flex: 0 0 auto; +} + .gallery-tags-field { display: grid; gap: 5px; @@ -5109,6 +5119,101 @@ body.ui-simple-mode .simple-mode-panel { line-height: var(--ux-line-height-title); } +.simple-shared-results-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ux-space-2); +} + +.simple-shared-results-list > .empty-history { + grid-column: 1 / -1; +} + +.simple-shared-result-item { + min-width: 0; + min-height: var(--ux-space-20); + padding: var(--ux-space-0); + display: grid; + grid-template-columns: var(--ux-space-20) minmax(0, 1fr); + overflow: hidden; + border: var(--ux-border-width) var(--ux-border-style) var(--ux-border-2); + border-radius: var(--ux-radius-card); + background: var(--ux-bg-2); + color: var(--ux-text-2); + text-align: left; + transition: var(--ux-transition-card); +} + +.simple-shared-result-item:hover { + border-color: var(--ux-primary-4); + box-shadow: var(--ux-shadow-2); + transform: translateY(calc(var(--ux-space-micro) * -1)); +} + +.simple-shared-result-item:active { + border-color: var(--ux-primary-6); + transform: translateY(var(--ux-space-0)); + transition: none; +} + +.simple-shared-result-item:focus-visible { + outline: none; + box-shadow: var(--ux-focus-ring-primary); +} + +.simple-shared-result-item > img, +.simple-shared-result-item > .shared-result-thumb { + width: 100%; + height: 100%; + min-height: var(--ux-space-20); + display: block; + object-fit: cover; + background: var(--ux-fill-2); +} + +.simple-shared-result-copy { + min-width: 0; + padding: var(--ux-space-2); + display: grid; + align-content: center; + gap: var(--ux-space-micro); +} + +.simple-shared-result-copy strong, +.simple-shared-result-copy span, +.simple-shared-result-copy p { + min-width: 0; + margin: var(--ux-space-0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-shared-result-copy strong { + color: var(--ux-text-1); + font-size: var(--ux-font-size-body-small); +} + +.simple-shared-result-copy span, +.simple-shared-result-copy p { + color: var(--ux-text-3); + font-size: var(--ux-font-size-caption); + line-height: var(--ux-line-height-caption); +} + +.simple-shared-results-empty { + min-height: var(--ux-control-height-touch); + padding: var(--ux-space-3); + display: flex; + align-items: center; + justify-content: center; + gap: var(--ux-space-2); + border: var(--ux-border-width) dashed var(--ux-border-3); + border-radius: var(--ux-radius-card); + color: var(--ux-text-3); + font-size: var(--ux-font-size-body-small); +} + .simple-gallery-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -5688,16 +5793,11 @@ body.ui-simple-mode .toast-message .ux-icon { } .simple-first-run-checklist { - position: fixed; - z-index: 12; - right: var(--ux-space-6); - bottom: var(--ux-space-6); - width: min(calc(100% - (var(--ux-space-6) * 2)), var(--ux-checklist-width)); - padding: var(--ux-space-4); + width: 100%; + padding: var(--ux-space-3); border: var(--ux-border-width) var(--ux-border-style) var(--ux-border-2); border-radius: var(--ux-radius-card); - background: var(--ux-bg-2); - box-shadow: var(--ux-shadow-3); + background: var(--ux-fill-1); color: var(--ux-text-1); } @@ -5718,6 +5818,7 @@ body.ui-simple-mode .toast-message .ux-icon { margin: var(--ux-space-3) var(--ux-space-0) var(--ux-space-0); padding: var(--ux-space-0); display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ux-space-2); list-style: none; } @@ -5846,6 +5947,10 @@ body.ui-simple-mode .toast-message .ux-icon { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .simple-shared-results-list { + grid-template-columns: minmax(0, 1fr); + } + .simple-scenario-card:last-child:nth-child(odd) { grid-column: auto; aspect-ratio: var(--ux-card-cover-ratio); @@ -5880,11 +5985,12 @@ body.ui-simple-mode .toast-message .ux-icon { } .simple-first-run-checklist { - right: var(--ux-space-3); - bottom: var(--ux-space-3); - width: min(calc(100% - (var(--ux-space-4) * 2)), var(--ux-checklist-width)); padding: var(--ux-space-3); } + + .simple-first-run-checklist ol { + grid-template-columns: minmax(0, 1fr); + } } /* Simple mode: end */ diff --git a/tests/test_api.py b/tests/test_api.py index 26f665e..e0bdab0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2551,6 +2551,53 @@ def test_edit_passes_mask_and_options(make_client, settings_factory): assert field_names == ["image", "mask"] +def test_edit_composites_original_pixels_outside_mask(make_client, settings_factory): + settings = settings_factory(default_api_key="sk-test") + client, fake, _ = make_client(settings=settings) + + def encoded(image: Image.Image) -> str: + output = BytesIO() + image.save(output, format="PNG") + return base64.b64encode(output.getvalue()).decode("ascii") + + source = Image.new("RGBA", (4, 4), (220, 30, 30, 255)) + generated = Image.new("RGBA", (4, 4), (20, 80, 220, 255)) + mask = Image.new("RGBA", (4, 4), (255, 255, 255, 255)) + mask.putpixel((1, 1), (255, 255, 255, 0)) + fake.run_multipart.return_value = { + "data": [{"b64_json": encoded(generated)}], + "created": 0, + } + + response = client.post( + "/api/edit", + json={ + "api_key": "sk-test", + "prompt": "只修改蒙版透明区域", + "model": "gpt-image-2", + "output_format": "png", + "image": { + "name": "source.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{encoded(source)}", + }, + "mask": { + "name": "mask.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{encoded(mask)}", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + with Image.open(payload["saved_image_path"]) as result: + rgba = result.convert("RGBA") + assert rgba.getpixel((0, 0)) == (220, 30, 30, 255) + assert rgba.getpixel((1, 1)) == (20, 80, 220, 255) + assert payload["metadata"]["mask_composited"] is True + + def test_edit_invalid_mask_returns_actionable_input_error(make_client, settings_factory): settings = settings_factory(default_api_key="sk-test") client, fake, _ = make_client(settings=settings) diff --git a/tests/test_image_jobs.py b/tests/test_image_jobs.py index 5e64ce8..78070cd 100644 --- a/tests/test_image_jobs.py +++ b/tests/test_image_jobs.py @@ -26,6 +26,12 @@ def _image_payload() -> dict[str, str]: } +def _rgba_png_b64(image: Image.Image) -> str: + output = BytesIO() + image.save(output, format="PNG") + return base64.b64encode(output.getvalue()).decode("ascii") + + @pytest.mark.parametrize( ("size", "mode", "has_input", "preferred_transport", "expected"), [ @@ -155,6 +161,48 @@ def test_image_job_routes_six_person_reference_to_responses(make_client, setting fake.run_multipart.assert_not_awaited() +def test_responses_masked_edit_preserves_pixels_outside_mask(make_client, settings_factory) -> None: + settings = settings_factory(default_api_key="sk-server", default_size="16x16") + client, fake, _ = make_client(settings=settings) + source = Image.new("RGBA", (16, 16), (220, 30, 30, 255)) + generated = Image.new("RGBA", (16, 16), (20, 80, 220, 255)) + mask = Image.new("RGBA", (16, 16), (255, 255, 255, 255)) + mask.putpixel((5, 5), (255, 255, 255, 0)) + fake.run_file_upload.return_value = {"id": "file-source"} + fake.run_responses.return_value = { + "data": [{"b64_json": _rgba_png_b64(generated)}], + "created": 1, + } + + response = client.post( + "/api/image-jobs", + json={ + "prompt": "只修改蒙版透明区域", + "mode": "edit", + "size": "16x16", + "image": { + "name": "source.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{_rgba_png_b64(source)}", + }, + "mask": { + "name": "mask.png", + "type": "image/png", + "data_url": f"data:image/png;base64,{_rgba_png_b64(mask)}", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["transport"] == "responses-image" + with Image.open(payload["saved_image_path"]) as result: + rgba = result.convert("RGBA") + assert rgba.getpixel((0, 0)) == (220, 30, 30, 255) + assert rgba.getpixel((5, 5)) == (20, 80, 220, 255) + assert payload["metadata"]["mask_composited"] is True + + @pytest.mark.parametrize("mode", ["edit", "reference", "variant"]) def test_image_job_rejects_input_modes_without_an_image(make_client, settings_factory, mode: str) -> None: settings = settings_factory(default_api_key="sk-server") diff --git a/tests/test_logo_placement.py b/tests/test_logo_placement.py index f23484e..91a234f 100644 --- a/tests/test_logo_placement.py +++ b/tests/test_logo_placement.py @@ -11,6 +11,7 @@ def _run_logo_policy(expression: str) -> object: script = f""" import {{ calculateLogoPlacementScore, + calculateOfficialLogoPixelMatch, calculateRegionComplexity, calculateRegionTextEdgePenalty, chooseLogoPlacement, @@ -40,6 +41,49 @@ def test_logo_stays_at_standard_position_for_marginal_improvement() -> None: assert result == {"x": 42, "y": 42, "width": 174, "height": 54} +def test_official_logo_pixel_match_accepts_lossless_and_small_color_drift() -> None: + result = _run_logo_policy( + "(() => {" + "const logo = { data: Uint8ClampedArray.from([" + "10,20,30,255, 40,160,90,255, 250,250,250,0, 90,80,70,240" + "]) };" + "const exact = { data: Uint8ClampedArray.from([" + "10,20,30,255, 40,160,90,255, 1,2,3,255, 90,80,70,255" + "]) };" + "const drift = { data: Uint8ClampedArray.from([" + "26,5,48,255, 58,143,108,255, 1,2,3,255, 90,80,70,255" + "]) };" + "return {" + "exact: calculateOfficialLogoPixelMatch(exact, logo)," + "drift: calculateOfficialLogoPixelMatch(drift, logo)," + "};" + "})()" + ) + + assert result["exact"] == {"score": 1, "matchedPixels": 2, "comparedPixels": 2} + assert result["drift"] == {"score": 1, "matchedPixels": 2, "comparedPixels": 2} + + +def test_official_logo_pixel_match_rejects_unrelated_or_invalid_regions() -> None: + result = _run_logo_policy( + "(() => {" + "const logo = { data: Uint8ClampedArray.from([" + "10,20,30,255, 40,160,90,255, 250,250,250,0" + "]) };" + "const unrelated = { data: Uint8ClampedArray.from([" + "200,210,220,255, 180,20,30,255, 1,2,3,255" + "]) };" + "return {" + "unrelated: calculateOfficialLogoPixelMatch(unrelated, logo)," + "invalid: calculateOfficialLogoPixelMatch({ data: new Uint8ClampedArray(4) }, logo)," + "};" + "})()" + ) + + assert result["unrelated"] == {"score": 0, "matchedPixels": 0, "comparedPixels": 2} + assert result["invalid"] == {"score": 0, "matchedPixels": 0, "comparedPixels": 0} + + def test_logo_moves_only_for_absolute_and_relative_score_improvement() -> None: significant = _run_logo_policy( "chooseLogoPlacement([" diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 9f59b1a..6bc9afe 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None: settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8") assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js - assert 'from "./responses-settings.mjs?v=0.1.61"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.62"' in app_js assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js assert "function migrateStoredResponsesSettings" in settings_js @@ -38,9 +38,27 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None: assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js assert "composeLogoOverlayForCandidates" in app_js assert "createOfficialLogoCanvas" in app_js - assert 'from "./logo-placement.mjs?v=0.1.61"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.62"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js + assert "calculateOfficialLogoPixelMatch" in app_js + assert "findExistingOfficialLogo" in app_js + assert "composed.preserved" in app_js + assert "已有官方 LOGO,保留原位置且不重复贴入" in app_js + compose = app_js[ + app_js.index("async function composeLogoOverlayForCandidates") : + app_js.index("async function downscaleDataUrlForRisk") + ] + preserved_branch = compose.index("if (composed.preserved)") + persist_call = compose.index("persistFinalLogoImage") + assert preserved_branch < persist_call + assert "continue" in compose[preserved_branch:persist_call] + assert "logo_preserved: true" in compose[preserved_branch:persist_call] + storage_copy = app_js[ + app_js.index("function updateSelectedCandidateStorageText") : + app_js.index("function updateResultActionSurface") + ] + assert "selectedCandidate.logo_preserved" in storage_copy assert "expandLogoSafetyRegion" in placement_js assert "calculateRegionTextEdgePenalty" in placement_js assert "hasTransparentLogoBackground" in app_js @@ -317,6 +335,94 @@ def test_share_success_status_survives_recipient_list_reset() -> None: assert reset_index < success_index +def test_simple_mode_exposes_shared_results_inbox() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + index_html = (ROOT_DIR / "static" / "index.html").read_text(encoding="utf-8") + styles_css = (ROOT_DIR / "static" / "styles.css").read_text(encoding="utf-8") + + assert 'id="simpleSharedResultsSection"' in index_html + assert 'id="simpleSharedResultsList"' in index_html + assert 'id="simpleSharedResultsEmpty"' in index_html + assert 'id="simpleRefreshSharedResultsButton"' in index_html + assert index_html.index('id="simpleSharedResultsSection"') < index_html.index('id="simpleGallerySection"') + simple_shared_html = index_html[ + index_html.index('id="simpleSharedResultsSection"') : index_html.index('id="simpleGallerySection"') + ] + assert 'aria-live=' not in simple_shared_html + + refs = app_js[app_js.index("const refs = {") : app_js.index("function loadJSON")] + assert 'simpleSharedResultsList: document.querySelector("#simpleSharedResultsList")' in refs + assert 'simpleSharedResultsEmpty: document.querySelector("#simpleSharedResultsEmpty")' in refs + assert 'simpleRefreshSharedResultsButton: document.querySelector("#simpleRefreshSharedResultsButton")' in refs + assert 'simpleSharedResultsSection: document.querySelector("#simpleSharedResultsSection")' in refs + + renderer = app_js[ + app_js.index("function renderSharedResults") : app_js.index("async function refreshSharedResults") + ] + assert "refs.simpleSharedResultsList" in renderer + assert "refs.simpleSharedResultsEmpty" in renderer + + bindings = app_js[app_js.index("function bindEvents()") : app_js.index("async function init()")] + assert "refs.simpleRefreshSharedResultsButton?.addEventListener(\"click\", refreshSharedResults)" in bindings + assert "bindSharedResultsList(refs.simpleSharedResultsList)" in bindings + + assert ".simple-shared-results-list" in styles_css + assert ".simple-shared-result-item" in styles_css + shared_list_css = styles_css[ + styles_css.index(".simple-shared-results-list {") : styles_css.index(".simple-shared-result-item {") + ] + assert "grid-template-columns: repeat(2, minmax(0, 1fr));" in shared_list_css + assert ".simple-shared-results-list > .empty-history" in styles_css + assert "grid-column: 1 / -1;" in styles_css[ + styles_css.index(".simple-shared-results-list > .empty-history") : + styles_css.index(".simple-shared-result-item {") + ] + mobile_css = styles_css[styles_css.index("@media (max-width: 820px)") :] + assert ".simple-shared-results-list {\n grid-template-columns: minmax(0, 1fr);" in mobile_css + + +def test_received_share_actions_cannot_rerun_or_complete_onboarding() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + + feedback_visibility = app_js[ + app_js.index("function updateFeedbackPanelVisibility") : + app_js.index("function buildFeedbackPayload") + ] + assert "currentResultIsShared()" in feedback_visibility + + bad_feedback = app_js[ + app_js.index("async function submitBadFeedbackReason") : + app_js.index("async function regenerateFromBadFeedback") + ] + assert "!currentResultIsShared()" in bad_feedback + + open_share = app_js[ + app_js.index("function openSharedResult") : app_js.index("function bindSharedResultsList") + ] + assert "state.isBusy" in open_share + assert "updateFeedbackPanelVisibility()" in open_share + assert "updateResultActionSurface()" in open_share + assert "shared_generated_image_id" in open_share + assert "\n generated_image_id: share.generated_image_id" not in open_share + + bindings = app_js[app_js.index("function bindEvents()") : app_js.index("async function init()")] + download_binding = bindings[ + bindings.index('refs.downloadButton?.addEventListener("click"') : + bindings.index('document.addEventListener("click"') + ] + assert "!currentResultIsShared()" in download_binding + + simple_result_surface = app_js[ + app_js.index("function updateSimpleResultSurface") : app_js.index("function setResultSizeWarning") + ] + assert "currentResultIsShared()" in simple_result_surface + + anonymous = app_js[ + app_js.index("function applyAnonymousShellChrome") : app_js.index("function renderAvatarElement") + ] + assert 'refs.simpleSharedResultsSection?.classList.add("hidden")' in anonymous + + def test_result_displays_and_restores_size_mismatch_warning() -> None: app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") index_html = (ROOT_DIR / "static" / "index.html").read_text(encoding="utf-8") @@ -332,6 +438,36 @@ def test_result_displays_and_restores_size_mismatch_warning() -> None: assert "setError(sizeMismatchMessage)" not in app_js +def test_result_previews_show_the_complete_image_without_grid_clipping() -> None: + styles_css = (ROOT_DIR / "static" / "styles.css").read_text(encoding="utf-8") + + preview_css = styles_css[ + styles_css.index(".preview-frame img {") : styles_css.index(".preview-frame img.visible") + ] + assert "min-width: 0;" in preview_css + assert "min-height: 0;" in preview_css + assert "object-fit: contain;" in preview_css + + candidate_css = styles_css[ + styles_css.index(".candidate-button img {") : styles_css.index(".candidate-button span {") + ] + assert "object-fit: contain;" in candidate_css + assert "object-fit: cover;" not in candidate_css + + +def test_gallery_favorite_checkbox_keeps_label_close() -> None: + styles_css = (ROOT_DIR / "static" / "styles.css").read_text(encoding="utf-8") + + toggle_css = styles_css[ + styles_css.index(".gallery-favorite-toggle input {") : + styles_css.index(".gallery-tags-field {") + ] + assert "width: 16px;" in toggle_css + assert "height: 16px;" in toggle_css + assert "min-height: 0;" in toggle_css + assert "flex: 0 0 auto;" in toggle_css + + def test_size_mismatch_warning_prefers_server_summary_for_mixed_candidates() -> None: app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") function_source = app_js[ @@ -1809,7 +1945,12 @@ def test_simple_mode_css_uses_the_phase_zero_token_layer() -> None: assert ".simple-input:focus:not(:disabled)" in simple_css assert ".simple-textarea:focus:not(:disabled)" in simple_css assert ".simple-dynamic-input:focus:not(:disabled)" in simple_css - assert "width: min(calc(100% - (var(--ux-space-4) * 2)), var(--ux-checklist-width));" in simple_css + checklist_css = simple_css[ + simple_css.index(".simple-first-run-checklist {") : + simple_css.index(".simple-first-run-checklist header {") + ] + assert "width: 100%;" in checklist_css + assert "position: fixed;" not in checklist_css assert "icons.svg#icon-" in (ROOT_DIR / "static" / "index.html").read_text(encoding="utf-8") diff --git a/tests/test_storage.py b/tests/test_storage.py index dbfb640..2de6ffb 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -11,6 +11,7 @@ from picgen.errors import APIError from picgen.storage import ( + composite_masked_edit_image, detect_image_dimensions, detect_image_mime, extension_for_mime, @@ -36,6 +37,64 @@ def _oriented_jpeg_bytes(width: int, height: int, orientation: int) -> bytes: return output.getvalue() +def test_masked_edit_composite_preserves_every_pixel_outside_transparent_mask() -> None: + source = Image.new("RGBA", (4, 4), (220, 30, 30, 255)) + generated = Image.new("RGBA", (4, 4), (20, 80, 220, 255)) + mask = Image.new("RGBA", (4, 4), (255, 255, 255, 255)) + mask.putpixel((1, 1), (255, 255, 255, 0)) + mask.putpixel((2, 2), (255, 255, 255, 0)) + + def encode(image: Image.Image) -> bytes: + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + result_bytes, result_mime, metadata = composite_masked_edit_image( + source_image_bytes=encode(source), + mask_image_bytes=encode(mask), + generated_image_bytes=encode(generated), + generated_image_mime="image/png", + ) + + with Image.open(BytesIO(result_bytes)) as result: + assert result.convert("RGBA").getpixel((0, 0)) == (220, 30, 30, 255) + assert result.convert("RGBA").getpixel((3, 3)) == (220, 30, 30, 255) + assert result.convert("RGBA").getpixel((1, 1)) == (20, 80, 220, 255) + assert result.convert("RGBA").getpixel((2, 2)) == (20, 80, 220, 255) + assert result_mime == "image/png" + assert metadata == { + "mask_composited": True, + "mask_preserve_mode": "inverse_alpha", + "mask_source_size": "4x4", + } + + +def test_masked_edit_composite_returns_to_source_dimensions_before_preserving_pixels() -> None: + source = Image.new("RGBA", (4, 6), (220, 30, 30, 255)) + generated = Image.new("RGBA", (2, 2), (20, 80, 220, 255)) + mask = Image.new("RGBA", source.size, (255, 255, 255, 255)) + mask.putpixel((2, 3), (255, 255, 255, 0)) + + def encode(image: Image.Image) -> bytes: + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + result_bytes, _, _ = composite_masked_edit_image( + source_image_bytes=encode(source), + mask_image_bytes=encode(mask), + generated_image_bytes=encode(generated), + generated_image_mime="image/png", + ) + + with Image.open(BytesIO(result_bytes)) as result: + rgba = result.convert("RGBA") + assert rgba.size == source.size + assert rgba.getpixel((0, 0)) == (220, 30, 30, 255) + assert rgba.getpixel((3, 5)) == (220, 30, 30, 255) + assert rgba.getpixel((2, 3)) == (20, 80, 220, 255) + + def test_itinerary_map_svg_requires_coordinates(tmp_path: Path) -> None: from picgen.itinerary_map import build_itinerary_map_plan diff --git a/uv.lock b/uv.lock index 628a2b5..956c2f0 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.61" +version = "0.1.62" source = { editable = "." } dependencies = [ { name = "anyio" },