From 04f1f0f7c22031ae11035bc518ad1fa076e051f3 Mon Sep 17 00:00:00 2001 From: Minor Date: Sun, 12 Jul 2026 07:56:04 +0000 Subject: [PATCH] fix: close 0.1.66 release blockers --- README.md | 21 +- docker-compose.yml | 2 +- pyproject.toml | 2 +- scripts/docker-build-push.sh | 2 +- src/picgen/auth.py | 44 ++- src/picgen/itinerary_map.py | 111 +++----- src/picgen/routes.py | 109 ++++++-- src/picgen/storage.py | 38 ++- src/picgen/upstream/errors.py | 20 +- static/app.js | 323 +++++++++++++-------- static/index.html | 16 +- static/logo-placement.mjs | 47 ++++ static/styles.css | 143 ++++++++++ tests/test_api.py | 133 ++++++++- tests/test_auth.py | 91 ++++++ tests/test_itinerary_map.py | 106 +++++++ tests/test_logo_placement.py | 90 ++++++ tests/test_simple_shell_styles.py | 65 +++++ tests/test_static_assets.py | 449 ++++++++++++++++++++++++++++-- tests/test_storage.py | 73 +++++ tests/test_upstream_client.py | 14 + uv.lock | 2 +- 22 files changed, 1606 insertions(+), 295 deletions(-) create mode 100644 tests/test_simple_shell_styles.py diff --git a/README.md b/README.md index d6d1ff2..74face6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.65**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.66**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,14 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.65 主要特性 +## 0.1.66 主要特性 + +- **简洁模式壳层统一**:顶栏、状态、预览和页脚使用既有 Arco token 重做;专业模式除模式按钮和“我的收藏”需求文案外保持原像素。 +- **编辑 Logo 缩放检测闭环**:检测坐标按源画布到结果画布的宽度比例缩放,缺失尺寸会先补齐;保留判定记录匹配率、像素数、阈值和候选关联,便于追查误判。 +- **蒙版与错误边界加固**:PNG-8 `tRNS` 蒙版恢复逐像素保留;空 `param` 可继续触发候选数降级;深层错误体和 Pillow 解压炸弹异常均受控处理。 +- **收藏与聊天状态诚实**:“团队灵感流”改为“我的收藏”,可见共享图片支持各自收藏;取消筛选会同步隐藏说明,聊天轮询不重绘未变化消息且隔离跨房间和乱序状态。 +- **行程图稳定性提升**:字体子集失败会继续尝试候选目录,SVG 渲染移出事件循环,长标题和副标题分档缩小并安全截断。 +- **多图结果数量透明**:Responses 请求多张但上游少返回时,结果区明确显示请求数和返回数。 - **模式切换更醒目**:简洁模式和专业模式的切换按钮使用主绿色与白色图文,在桌面和移动端都更容易识别。 @@ -129,10 +136,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.65 . +docker build -t minorli/picgen:0.1.66 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.65 + minorli/picgen:0.1.66 ``` 或: @@ -147,10 +154,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.65`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.66`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.65 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.66 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -263,7 +270,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.65 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.66 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index af77395..7907d10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.65 + image: minorli/picgen:0.1.66 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index 5ee1ab0..7453979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.65" +version = "0.1.66" 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 9903e13..44e02f3 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.65}" +VERSION="${VERSION:-0.1.66}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/auth.py b/src/picgen/auth.py index fdb4545..78fc19f 100644 --- a/src/picgen/auth.py +++ b/src/picgen/auth.py @@ -1705,7 +1705,13 @@ def resolve_generated_image_id_from_source( ).fetchone() return int(row["id"]) if row is not None else None - def generated_image_detail_for_user(self, *, generated_image_id: int, user_id: int) -> dict[str, Any] | None: + def generated_image_detail_for_user( + self, + *, + generated_image_id: int, + user_id: int, + allow_viewer_access: bool = False, + ) -> dict[str, Any] | None: with self._lock, self._connect() as conn: row = conn.execute( """ @@ -1749,12 +1755,27 @@ def generated_image_detail_for_user(self, *, generated_image_id: int, user_id: i FROM generated_images gi JOIN generation_jobs j ON j.id = gi.job_id LEFT JOIN generated_image_metadata gim ON gim.generated_image_id = gi.id - WHERE gi.id = ? AND gi.user_id = ? + WHERE gi.id = ? AND (gi.user_id = ? OR ? = 1) """, - (generated_image_id, user_id), + (generated_image_id, user_id, 1 if allow_viewer_access else 0), ).fetchone() + viewer_is_admin = False + if row is not None and int(row["user_id"]) != user_id: + viewer = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() + viewer_is_admin = viewer is not None and str(viewer["role"] or "") == "admin" if row is None: return None + if int(row["user_id"]) != user_id: + saved_url = str(row["saved_image_url"] or "").strip().lstrip("/") + relative_path = saved_url.removeprefix("files/") + absolute_path = Path(str(row["saved_image_path"] or relative_path)) + if not self.can_user_access_output( + user_id=user_id, + is_admin=viewer_is_admin, + relative_path=relative_path, + absolute_path=absolute_path, + ): + return None item = _generated_image_row_to_dict(row) metadata: dict[str, Any] = {} try: @@ -2143,15 +2164,14 @@ def update_gallery_item( cleaned_tags = normalize_gallery_tags(tags) now = _now_text() with self._lock, self._connect() as conn: - image = conn.execute( - """ - SELECT id - FROM generated_images - WHERE id = ? AND user_id = ? - """, - (generated_image_id, user_id), - ).fetchone() - if image is None: + if ( + self.generated_image_detail_for_user( + generated_image_id=generated_image_id, + user_id=user_id, + allow_viewer_access=True, + ) + is None + ): raise PermissionError("无权整理这张图片") conn.execute( """ diff --git a/src/picgen/itinerary_map.py b/src/picgen/itinerary_map.py index 2972987..012f1f2 100644 --- a/src/picgen/itinerary_map.py +++ b/src/picgen/itinerary_map.py @@ -36,6 +36,8 @@ LOGO_HREF = "6renyou.png" TITLE_FONT_FAMILY = "PicGenRouteTitle" TITLE_FONT_RELATIVE_PATH = Path("fonts/zcool-xiaowei/ZCOOLXiaoWei-Regular.ttf") +TITLE_TEXT_TIERS = ((8, 80), (10, 64), (12, 52)) +SUBTITLE_TEXT_TIERS = ((20, 32), (24, 26), (30, 21)) logger = logging.getLogger(__name__) logging.getLogger("fontTools.subset").setLevel(logging.WARNING) INSTRUCTION_STOP_PREFIXES = ( @@ -64,6 +66,10 @@ _NOMINATIM_LOCKS: dict[int, asyncio.Lock] = {} +class _TitleFontUnavailable(Exception): + pass + + def _candidate_static_dirs() -> list[Path]: candidates: list[Path] = [] env_static_dir = os.getenv("PICGEN_STATIC_DIR") @@ -109,7 +115,7 @@ def _embedded_title_font_face_css_cached(glyphs: str) -> str: continue except Exception as exc: logger.warning("itinerary_title_font_subset_failed", extra={"error_type": type(exc).__name__}) - return "" + continue encoded = base64.b64encode(font_bytes).decode("ascii") return ( "/* ZCOOL XiaoWei, SIL Open Font License 1.1 */" @@ -117,7 +123,7 @@ def _embedded_title_font_face_css_cached(glyphs: str) -> str: "src:url(data:font/ttf;base64," f"{encoded}) format('truetype');font-weight:400;font-style:normal;font-display:block}}" ) - return "" + raise _TitleFontUnavailable def _embedded_title_font_face_css(text: str) -> str: @@ -127,10 +133,11 @@ def _embedded_title_font_face_css(text: str) -> str: glyphs = "".join(sorted(set(text))) if not glyphs: return "" - css = _embedded_title_font_face_css_cached(glyphs) - if not css: - _embedded_title_font_face_css_cached.cache_clear() - return css + try: + return _embedded_title_font_face_css_cached(glyphs) + except _TitleFontUnavailable: + return "" + COUNTRY_LABEL_ALIASES: tuple[tuple[str, tuple[str, ...]], ...] = ( ("意大利", ("意大利", "italy", "italia")), @@ -1374,6 +1381,14 @@ def _short_text(value: Any, *, limit: int) -> str: return f"{text[: max(1, limit - 1)]}…" +def _fit_heading_text(text: str, tiers: tuple[tuple[int, int], ...]) -> tuple[str, int]: + for character_limit, font_size in tiers: + if len(text) <= character_limit: + return text, font_size + character_limit, font_size = tiers[-1] + return f"{text[: character_limit - 1]}…", font_size + + def _overlap_area(box: tuple[float, float, float, float], other: tuple[float, float, float, float]) -> float: left = max(box[0], other[0]) top = max(box[1], other[1]) @@ -1384,58 +1399,6 @@ def _overlap_area(box: tuple[float, float, float, float], other: tuple[float, fl return (right - left) * (bottom - top) -def _dense_index_labels(points: list[dict[str, Any]], *, width: int, height: int) -> list[str]: - occupied: list[tuple[float, float, float, float]] = [] - nodes: list[str] = [] - candidates = ( - (-34.0, -34.0), - (34.0, -34.0), - (-34.0, 34.0), - (34.0, 34.0), - (0.0, -44.0), - (0.0, 44.0), - (-50.0, 0.0), - (50.0, 0.0), - (-58.0, -18.0), - (58.0, -18.0), - (-58.0, 18.0), - (58.0, 18.0), - ) - badge_w = 34.0 - badge_h = 28.0 - for index, point in enumerate(points): - x = float(point["x"]) - y = float(point["y"]) - best: tuple[float, float, tuple[float, float, float, float]] | None = None - best_score = float("inf") - for dx, dy in candidates: - cx = min(max(x + dx, 40.0), width - 480.0) - cy = min(max(y + dy, 184.0), height - 60.0) - box = (cx - badge_w / 2, cy - badge_h / 2, cx + badge_w / 2, cy + badge_h / 2) - overlap = sum(_overlap_area(box, item) for item in occupied) - distance = abs(dx) + abs(dy) - score = overlap * 100.0 + distance - if score < best_score: - best_score = score - best = (cx, cy, box) - if best is None: - continue - cx, cy, box = best - occupied.append(box) - leader = "" - if abs(cx - x) + abs(cy - y) > 34: - leader = f'' - nodes.append( - f'' - f"{leader}" - '' - '' - f'{index + 1}' - "" - ) - return nodes - - def _label_candidate_centers( index: int, point: dict[str, Any], @@ -1767,8 +1730,8 @@ def render_itinerary_map_svg( f'' for segment in route_segments ] - title_text = str(plan.get("title") or "定制旅行路线图") - subtitle_text = str(plan.get("subtitle") or "") + title_text, title_font_size = _fit_heading_text(str(plan.get("title") or "定制旅行路线图"), TITLE_TEXT_TIERS) + subtitle_text, subtitle_font_size = _fit_heading_text(str(plan.get("subtitle") or ""), SUBTITLE_TEXT_TIERS) title = _svg_text(title_text) subtitle = _svg_text(subtitle_text) safe_background_url = ( @@ -1840,11 +1803,6 @@ def render_itinerary_map_svg( has_logo=reserve_logo, avoid_boxes=callout_boxes, ) - index_nodes = ( - _dense_index_labels(points, width=width, height=height) - if dense_layout and len(points) > len(label_nodes) - else [] - ) doodle_nodes = [] if safe_background_url else _decorative_doodles(points, width=width, height=height) logo_group = ( @@ -1907,17 +1865,22 @@ def render_itinerary_map_svg( f"font-family:'{TITLE_FONT_FAMILY}','ZCOOL XiaoWei','Noto Serif CJK SC'," "'Source Han Serif SC','Songti SC',serif;" "letter-spacing:0}", - ".title-shadow{font-size:80px;font-weight:900;fill:#4b2d16;opacity:.18;filter:url(#titleCast)}", + f".title-shadow{{font-size:{title_font_size}px;font-weight:900;fill:#4b2d16;" + "opacity:.18;filter:url(#titleCast)}", ".title-wash{fill:#f0d49a;fill-opacity:.34;stroke:#b9823f;stroke-width:1.1;opacity:.62}", - ".title-brush{font-size:80px;font-weight:900;fill:none;stroke:#fff4d7;stroke-width:8.5;" + f".title-brush{{font-size:{title_font_size}px;font-weight:900;fill:none;stroke:#fff4d7;stroke-width:8.5;" "stroke-linejoin:round;stroke-linecap:round;paint-order:stroke;opacity:.92;filter:url(#titleBrushRough)}", - ".title-gold-edge{font-size:80px;font-weight:900;fill:none;stroke:url(#titleGold);stroke-width:2.4;" + f".title-gold-edge{{font-size:{title_font_size}px;font-weight:900;fill:none;stroke:url(#titleGold);" + "stroke-width:2.4;" "stroke-linejoin:round;stroke-linecap:round;opacity:.82;filter:url(#titleBrushRough)}", - ".title{font-size:80px;font-weight:900;fill:url(#titleInk);paint-order:stroke;stroke:#2a1609;" + f".title{{font-size:{title_font_size}px;font-weight:900;fill:url(#titleInk);paint-order:stroke;" + "stroke:#2a1609;" "stroke-width:.55;stroke-linejoin:round;filter:url(#titleBrushRough)}", - ".subtitle-glow{font-size:32px;font-weight:700;fill:none;stroke:#fff2d7;stroke-width:5;" + f".subtitle-glow{{font-size:{subtitle_font_size}px;font-weight:700;fill:none;stroke:#fff2d7;" + "stroke-width:5;" "stroke-linejoin:round;opacity:.88}", - ".subtitle{font-size:32px;font-weight:700;fill:#7a4c22;paint-order:stroke;stroke:#fff8e6;" + f".subtitle{{font-size:{subtitle_font_size}px;font-weight:700;fill:#7a4c22;paint-order:stroke;" + "stroke:#fff8e6;" "stroke-width:.9;stroke-linejoin:round}", ".title-ornament{fill:none;stroke:#9b6b35;stroke-width:1.7;stroke-linecap:round;opacity:.44}", ".country-label{font:760 46px system-ui,'PingFang SC','Microsoft YaHei',sans-serif;fill:#735b3e;" @@ -1925,8 +1888,6 @@ def render_itinerary_map_svg( "stroke-opacity:.72}", ".country-label.small{font-size:36px;opacity:.50}", ".route-dot-index{font:800 10px system-ui,'PingFang SC','Microsoft YaHei',sans-serif;fill:#ffffff}", - ".map-index{font:800 15px system-ui,'PingFang SC','Microsoft YaHei',sans-serif;fill:#7f5f45}", - ".map-index-leader{stroke:#7f5f45;stroke-width:1.05;stroke-linecap:round;opacity:.26}", ".callout-scroll{fill:#fff4df;fill-opacity:.60;stroke:#b99662;stroke-width:.85;opacity:.92}", ".callout-leader{fill:none;stroke:#735a42;stroke-width:.9;stroke-linecap:round;opacity:.24}", ".callout-chip{fill:#7f5f45;stroke:#fff7e7;stroke-width:2.2}", @@ -1982,9 +1943,7 @@ def render_itinerary_map_svg( '', *country_nodes, "", - '', - *index_nodes, - "", + '', '', *label_nodes, "", diff --git a/src/picgen/routes.py b/src/picgen/routes.py index 7208c51..e7f9a10 100644 --- a/src/picgen/routes.py +++ b/src/picgen/routes.py @@ -213,8 +213,11 @@ def _highest_quality_image_options(options: dict[str, Any]) -> dict[str, Any]: def _responses_reasoning_options( model: str, reasoning_effort: str, + *, + default_model: str = DEFAULT_RESPONSES_MODEL, ) -> dict[str, dict[str, str]]: - if model.strip() != DEFAULT_RESPONSES_MODEL: + supported_models = {DEFAULT_RESPONSES_MODEL, default_model.strip()} + if model.strip() not in supported_models: return {} return {"reasoning": {"effort": reasoning_effort}} @@ -440,7 +443,11 @@ def _resolve_image_execution_plan( ) reasoning_effort = ( requested_reasoning_effort - if _responses_reasoning_options(model, requested_reasoning_effort) + if _responses_reasoning_options( + model, + requested_reasoning_effort, + default_model=settings.default_responses_model, + ) else "" ) return ImageExecutionPlan( @@ -921,6 +928,8 @@ def _decode_trailing_error_json(details: str) -> object | None: for start in dict.fromkeys(starts): try: return json.loads(details[start:]) + except RecursionError: + return None except (TypeError, ValueError): continue return None @@ -933,17 +942,27 @@ def _structured_error_parameters(details: str | None) -> tuple[str, ...]: if payload is None: return () - def _walk(value: object) -> tuple[str, ...]: + def _walk(value: object, depth: int = 0) -> tuple[str, ...]: + if depth > 8: + return () if isinstance(value, dict): direct = tuple( child for key, child in value.items() - if key.lower() in {"param", "parameter"} and isinstance(child, str) + if key.lower() in {"param", "parameter"} + and isinstance(child, str) + and child.strip() + ) + nested = tuple( + parameter + for child in value.values() + for parameter in _walk(child, depth + 1) ) - nested = tuple(parameter for child in value.values() for parameter in _walk(child)) return (*direct, *nested) if isinstance(value, list): - return tuple(parameter for child in value for parameter in _walk(child)) + return tuple( + parameter for child in value for parameter in _walk(child, depth + 1) + ) return () return _walk(payload) @@ -1375,7 +1394,11 @@ async def _generate_itinerary_artwork( upstream_payload: dict[str, Any] = { "model": model, "instructions": ITINERARY_ARTWORK_INSTRUCTIONS, - **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), + **_responses_reasoning_options( + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, + ), "stream": True, "parallel_tool_calls": False, "tool_choice": {"type": "image_generation"}, @@ -1467,13 +1490,15 @@ async def _generate_itinerary_artwork( code="upstream_error", ) - svg_text = render_itinerary_map_svg( - plan, - width=width, - height=height, - background_image_url=background_data_url, - logo_href="", - reserve_logo_area=parsed.logo_requested, + svg_text = await anyio.to_thread.run_sync( + lambda: render_itinerary_map_svg( + plan, + width=width, + height=height, + background_image_url=background_data_url, + logo_href="", + reserve_logo_area=parsed.logo_requested, + ) ) overlay = await anyio.to_thread.run_sync( lambda: save_itinerary_map_svg( @@ -1641,7 +1666,11 @@ async def _complete_itinerary_plan_with_ai_coordinates( upstream_payload = { "model": model, "instructions": ITINERARY_COORDINATE_INSTRUCTIONS, - **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), + **_responses_reasoning_options( + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, + ), "input": [ { "role": "user", @@ -1815,13 +1844,15 @@ async def handle_itinerary_map_render( } try: - svg_text = render_itinerary_map_svg( - plan, - width=width, - height=height, - background_image_url=parsed.background_image_url, - logo_href="", - reserve_logo_area=parsed.logo_requested, + svg_text = await anyio.to_thread.run_sync( + lambda: render_itinerary_map_svg( + plan, + width=width, + height=height, + background_image_url=parsed.background_image_url, + logo_href="", + reserve_logo_area=parsed.logo_requested, + ) ) saved = await anyio.to_thread.run_sync( lambda: save_itinerary_map_svg( @@ -3620,7 +3651,11 @@ async def _create_team_chat_bot_reply( upstream_payload = { "model": model, "instructions": TEAM_CHAT_BOT_INSTRUCTIONS, - **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), + **_responses_reasoning_options( + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, + ), "input": [ { "role": "user", @@ -3781,7 +3816,11 @@ async def handle_copyright_risk( upstream_payload = { "model": model, "instructions": COPYRIGHT_RISK_INSTRUCTIONS, - **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), + **_responses_reasoning_options( + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, + ), "input": [ { "role": "user", @@ -3844,7 +3883,9 @@ async def _transcribe(part: dict[str, Any]) -> list[str]: "model": model, "instructions": TEXT_FIDELITY_INSTRUCTIONS, **_responses_reasoning_options( - model, settings.default_responses_reasoning_effort + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, ), "input": [ { @@ -3878,7 +3919,11 @@ async def _transcribe(part: dict[str, Any]) -> list[str]: upstream_payload = { "model": model, "instructions": TEXT_FIDELITY_INSTRUCTIONS, - **_responses_reasoning_options(model, settings.default_responses_reasoning_effort), + **_responses_reasoning_options( + model, + settings.default_responses_reasoning_effort, + default_model=settings.default_responses_model, + ), "input": [ { "role": "user", @@ -4135,7 +4180,11 @@ def _job_metadata(path: str, body: Any, user: AuthUser, settings: Settings) -> d requested_reasoning_effort = settings.default_responses_reasoning_effort default_reasoning_effort = ( requested_reasoning_effort - if _responses_reasoning_options(effective_model, requested_reasoning_effort) + if _responses_reasoning_options( + effective_model, + requested_reasoning_effort, + default_model=settings.default_responses_model, + ) else "" ) default_size = { @@ -4946,7 +4995,11 @@ async def handle_responses_image( requested_reasoning_effort = ( parsed.reasoning_effort or settings.default_responses_reasoning_effort ) - reasoning_options = _responses_reasoning_options(model, requested_reasoning_effort) + reasoning_options = _responses_reasoning_options( + model, + requested_reasoning_effort, + default_model=settings.default_responses_model, + ) reasoning_effort = requested_reasoning_effort if reasoning_options else "" _ensure_no_restricted_destination_text(parsed.prompt) diff --git a/src/picgen/storage.py b/src/picgen/storage.py index 568ce5f..c03f2e3 100644 --- a/src/picgen/storage.py +++ b/src/picgen/storage.py @@ -8,6 +8,7 @@ import tempfile import uuid import warnings +from contextlib import ExitStack from datetime import datetime, timedelta from http import HTTPStatus from io import BytesIO @@ -279,19 +280,40 @@ def composite_masked_edit_image( 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, + for image_label, image_bytes in ( + ("source", source_image_bytes), + ("mask", mask_image_bytes), + ("generated", generated_image_bytes), ): + dimensions = detect_image_dimensions(image_bytes) + if dimensions and dimensions[0] * dimensions[1] > MAX_EXACT_IMAGE_PIXELS: + raise ValueError(f"{image_label} image exceeds {MAX_EXACT_IMAGE_PIXELS} pixels") + + with ExitStack() as stack: + image_files: list[tuple[str, Image.Image]] = [] + for image_label, image_bytes in ( + ("source", source_image_bytes), + ("mask", mask_image_bytes), + ("generated", generated_image_bytes), + ): + try: + image_file = stack.enter_context(Image.open(BytesIO(image_bytes))) + except Image.DecompressionBombError as exc: + raise ValueError( + f"{image_label} image exceeds {MAX_EXACT_IMAGE_PIXELS} pixels" + ) from exc + width, height = image_file.size + if width * height > MAX_EXACT_IMAGE_PIXELS: + raise ValueError(f"{image_label} image exceeds {MAX_EXACT_IMAGE_PIXELS} pixels") + image_files.append((image_label, image_file)) + source_file, mask_file, generated_file = (image_file for _, image_file in image_files) source = ImageOps.exif_transpose(source_file).convert("RGBA") - mask = ImageOps.exif_transpose(mask_file) + mask_source = ImageOps.exif_transpose(mask_file) + mask = mask_source.convert("RGBA") 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(): + if "A" not in mask_source.getbands() and "transparency" not in mask_source.info: raise ValueError("mask image must contain an alpha channel") if generated.size != source.size: generated = ImageOps.fit( diff --git a/src/picgen/upstream/errors.py b/src/picgen/upstream/errors.py index a6ad27e..1f1fe16 100644 --- a/src/picgen/upstream/errors.py +++ b/src/picgen/upstream/errors.py @@ -14,13 +14,20 @@ def compact_log_text(value: str, limit: int = 300) -> str: return f"{cleaned[:limit]}..." +def _serialize_error_details(value: Any, fallback: str) -> str | None: + try: + return json.dumps(value, ensure_ascii=False, indent=2) + except (RecursionError, TypeError, ValueError): + return fallback.strip() or None + + def extract_error_message(response_body: str) -> tuple[str, str | None]: message = response_body.strip() or "上游接口返回了错误" details: str | None = None try: parsed_body = json.loads(response_body) - except json.JSONDecodeError: + except (json.JSONDecodeError, RecursionError): return message, response_body.strip() or None if isinstance(parsed_body, dict): @@ -47,11 +54,11 @@ def extract_error_message(response_body: str) -> tuple[str, str | None]: error_block = parsed_body.get("error") if isinstance(error_block, dict): message = str(error_block.get("message") or message) - details = json.dumps(error_block, ensure_ascii=False, indent=2) + details = _serialize_error_details(error_block, response_body) else: - details = json.dumps(parsed_body, ensure_ascii=False, indent=2) + details = _serialize_error_details(parsed_body, response_body) else: - details = json.dumps(parsed_body, ensure_ascii=False, indent=2) + details = _serialize_error_details(parsed_body, response_body) return redact_sensitive_text(message, limit=1000), redact_sensitive_text(details, limit=4000) @@ -104,7 +111,7 @@ def _error_values(details: str | None) -> set[str]: return set() try: parsed = json.loads(details) - except json.JSONDecodeError: + except (json.JSONDecodeError, RecursionError): return set() if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict): parsed = parsed["error"] @@ -147,7 +154,8 @@ def upstream_api_error( def coerce_error_payload(payload: Any, context: str) -> APIError: if isinstance(payload, dict): - message, details = extract_error_message(json.dumps(payload, ensure_ascii=False)) + serialized = _serialize_error_details(payload, "") + message, details = extract_error_message(serialized or "上游错误载荷无法序列化") else: message, details = extract_error_message(str(payload)) return APIError(502, f"{context}: {message}", details, code="upstream_error") diff --git a/static/app.js b/static/app.js index d3ce203..06121be 100644 --- a/static/app.js +++ b/static/app.js @@ -2,14 +2,16 @@ import { calculateLogoPlacementScore, calculateOfficialLogoPixelMatch, chooseLogoPlacement, -} from "./logo-placement.mjs?v=0.1.65" + createLogoPreservationDiagnostic, + scaleLogoDetectionPlacements, +} from "./logo-placement.mjs?v=0.1.66" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.65" +} from "./responses-settings.mjs?v=0.1.66" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" @@ -121,15 +123,6 @@ const DETAILED_ITINERARY_TEMPLATE = [ "- 漫画风格不能牺牲地理真实性;日期、距离、交通方式、酒店和核心景区不能省略,信息丰富但不拥挤。", ].join("\n") -const ITINERARY_GEOGRAPHY_GUARD = [ - "地图准确性保护:路线图必须优先服从真实地图坐标、用户提供的地图参考和明确的地理关系,不能只按画面美观重排地点。", - "- 如果用户提供了经纬度、导航距离、地点清单里的东西南北关系或转场方式,以这些资料为硬约束。", - "- 不要凭想象补地图;不确定地点准确落位时,不要把它画到看起来顺眼的位置,改用编号点、局部放大框、侧边行程表或“待核对位置”备注。", - "- 使用测绘式真实地图框架:默认北上南下、西左东右,城市、景区、湖泊、山脉、海岸、岛屿和边境的相对方位不能为了构图改变。", - "- 跨大区或跨国家转场必须使用总览图、局部放大框或飞行连接;不要把远距离城市压缩成相邻景点,也不要把南北或东西关系画反。", - "- 地点名相近或景区层级复杂时,先按用户给出的地理校验逐项落位,再画路线;缺少把握时保守表达为示意路线,不得伪装成精确地图。", -].join("\n") - const SIZE_PRESETS = [ "auto", "128x128", @@ -265,6 +258,7 @@ const state = { }, teamChatGroupContextExpanded: false, teamChatLastMessageId: 0, + teamChatMessageRequestSeq: 0, teamChatLocalMessageSeq: 0, teamChatUnreadTotal: 0, teamChatPollTimer: null, @@ -535,6 +529,8 @@ const refs = { resultHoverActions: document.querySelector("#resultHoverActions"), resultActions: document.querySelector("#resultActions"), resultCandidateStrip: document.querySelector("#resultCandidateStrip"), + resultCountNotice: document.querySelector("#resultCountNotice"), + resultCountNoticeText: document.querySelector("#resultCountNoticeText"), resultSizeWarning: document.querySelector("#resultSizeWarning"), resultSizeWarningText: document.querySelector("#resultSizeWarningText"), generationOverlay: document.querySelector("#generationOverlay"), @@ -3162,6 +3158,28 @@ function setResultSizeWarning(message = "") { refs.resultSizeWarning?.classList.toggle("hidden", !text) } +function resolveResultCountNotice(payload, returnedCount) { + if (payload?.transport !== "responses-image") { + return "" + } + const requestedCount = Math.max( + 0, + Math.floor(Number(payload?.requested_sample_count ?? payload?.sample_count) || 0), + ) + const actualCount = Math.max(0, Math.floor(Number(returnedCount) || 0)) + return requestedCount > 1 && actualCount < requestedCount + ? `本次请求 ${requestedCount} 张,上游返回 ${actualCount} 张` + : "" +} + +function setResultCountNotice(message = "") { + const text = String(message || "").trim() + if (refs.resultCountNoticeText) { + refs.resultCountNoticeText.textContent = text + } + refs.resultCountNotice?.classList.toggle("hidden", !text) +} + function normalizeUiMode(value) { return UI_MODES.has(value) ? value : "" } @@ -3429,89 +3447,6 @@ function parseItinerarySize() { return formatSizeValue(width, height) } -function itineraryThemePrompt() { - const theme = refs.itineraryThemeSelect?.value || "comic" - if (theme === "comic") { - return "水彩漫画路线图:柔和浅蓝/浅黄水彩底、清晰地图轮廓、红色粗路线、白心红点或圆点站位、手写感中文标题、地标小插画、车辆/飞机/火车/脚印小图标;画面亲切有旅行手帐感,但漫画风格不能牺牲地理真实性,地点相对位置、路线顺序、日期、距离、交通方式、酒店和核心景区不能省略。" - } - if (theme === "dark") { - return "深色高级手绘地形地图,午夜蓝与暖金路线,低饱和、高对比,适合高端旅行海报;保留真实山脉、湖泊、沙漠和城市层级,路线像精品旅行地图而不是导航截图。" - } - if (theme === "classic") { - return "复古高级手绘地形地图,羊皮纸、金色路线、轻微等高线和克制指南针元素,保持现代高级旅行质感;不要生成路线图例或线型说明框,地点落位仍必须服从真实地理。" - } - return "高级水彩漫画路线图,保留旅行定制海报的高级感,色彩克制、山野度假质感、路线清楚但画面不拥挤;使用红色粗路线、圆点站位和地标小插画;真实地貌、山脉、湖泊、海岸、岛屿、沙漠、城市和边境层级必须准确。" -} - -function drawItineraryLogoSafeArea() { - return "左上角预留自然干净的 LOGO 留白;不要让 AI 绘制或改造 6 人游 LOGO,不要画 LOGO 占位框,不要画边框,不要画白底底板,不要画贴纸底座或任何临时标识;最终由程序使用官方透明 PNG 原样贴入。" -} - -function stripCodeFence(value) { - const text = String(value || "").trim() - const match = text.match(/^```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)\s*```$/) - return (match ? match[1] : text).trim() -} - -function isCompleteItineraryPrompt(value) { - const text = stripCodeFence(value) - // 用户粘贴完整行程地图 prompt 时不再二次包裹,避免重复标题和要求。 - return text.includes("客户行程原文如下") - && text.includes("画面与信息要求") - && text.includes("地理正确性硬性要求") -} - -function normalizeCompleteItineraryPrompt(value) { - let text = stripCodeFence(value) - const legacyLogoSafeArea = ["左上角预留干净白底", "LOGO 安全区"].join(" ") - text = text - .replaceAll(`${legacyLogoSafeArea};不要让 AI 绘制或改造 6 人游 LOGO,最终由程序使用官方透明 PNG 原样贴入。`, drawItineraryLogoSafeArea()) - .replaceAll(legacyLogoSafeArea, "左上角预留自然干净的 LOGO 留白") - .replaceAll( - "LOGO 位置附近保留干净留白,背景尽量简单,避免图片元素和 LOGO 少量重合。", - "LOGO 位置附近保留自然干净背景,背景尽量简单;不要绘制 LOGO 占位框、边框、描边、白色底板、贴纸底座或任何临时标识。", - ) - if (!text.includes("地图准确性保护")) { - text = `${text}\n\n${ITINERARY_GEOGRAPHY_GUARD}` - } - if (!text.includes("不要画 LOGO 占位框")) { - text = `${text}\n\n- ${drawItineraryLogoSafeArea()}` - } - return text -} - -function buildAIItineraryMapPrompt({ title, subtitle, description, theme }) { - const normalizedTitle = String(title || "").trim() || "定制旅行行程地图" - const normalizedSubtitle = String(subtitle || "").trim() || "准确路线图" - const normalizedDescription = stripCodeFence(description) - if (isCompleteItineraryPrompt(normalizedDescription)) { - return normalizeCompleteItineraryPrompt(normalizedDescription) - } - return [ - "请生成一张漫画风格的高级定制旅行行程路线图海报,不是普通导航截图,也不是纯信息表格。", - `标题:${normalizedTitle}`, - `副标题:${normalizedSubtitle}`, - "", - "客户行程原文如下,请先理解日期、城市、景区、酒店、交通和活动关系,再转化为清晰的路线地图视觉:", - normalizedDescription, - "", - "画面与信息要求:", - "- 地理正确性硬性要求:所有地点落位、东西南北关系、前后路线顺序必须以真实地图为准;不能为了画面好看而调整地点相对位置,也不要把城市、景区顺序画反。", - "- 对任何目的地都要先按真实世界地图理解相对位置:城市、国家/地区边界、山脉、湖泊、海岸线、岛屿、沙漠、峡谷和主要交通走廊不能乱画。", - ITINERARY_GEOGRAPHY_GUARD, - "- 必须展示行程原文里的每一个日期,不要漏掉中间日期;日期必须逐日出现,像 5/18 这样的中间日期也必须单独标出;日期标签用小金色日期牌或清晰日程标签呈现。", - "- 地点层级要有主次;酒店可作为小字备注,不要把所有酒店全文挤满画面,但不能删掉用户明确给出的核心城市、景区、日期和活动。", - "- 每两个连续地点之间必须有路线连接,并必须标注大致距离或飞行/转场说明;若无法确定精确距离,用“约 xx km”或“飞行转场”这类合理估算,不要空着。", - "- 自驾/包车路线用实线或柔和路线带,飞机/长距离转场用虚线或飞行弧线,避免路线互相缠绕。", - "- 在每段连接线中间放一个很小的交通工具图标:自驾/包车用小车图标,飞机转场用飞机图标,步行/活动可用小脚印或点线;图标要小而精致,不遮挡地点和日期。", - "- 画面需要像高级旅行定制海报,适合发给客户预览;采用水彩漫画路线图表达,地图轮廓清晰、地貌层次准确、地标小插画精致;不要像低质 PPT、不要像手机地图截图。", - "- 漫画路线图视觉语言:柔和水彩铺底、红色粗路线、圆点站位、手写感标题、地标小插画、海/湖/山地用轻松但清晰的插画表达;但信息密度和真实地理不能下降。", - `- 视觉风格:${theme || itineraryThemePrompt()}`, - `- ${drawItineraryLogoSafeArea()}`, - "- 不要出现 OpenAI、API、debug、水印、二维码、虚构品牌 LOGO。", - ].join("\n") -} - function parseItineraryCoordinateStops(description) { const rows = String(description || "").split(/\r?\n/) const stops = [] @@ -4146,6 +4081,7 @@ function resetReviewStateForExternalResult() { state.lastReviewPayload = null state.lastFeedbackPayload = null state.lastFeedbackRating = null + setResultCountNotice("") setResultSizeWarning("") setError("") setRiskPanel("未检查", "这张图片尚未在当前工作区进行版权风险检查。") @@ -4391,13 +4327,18 @@ function toggleRailSection(sectionName, forceExpanded = null) { toggle.setAttribute("aria-expanded", String(shouldExpand)) } +function syncMyFavoritesSummary() { + const favoriteOnly = Boolean(refs.galleryFavoriteOnlyInput?.checked) + refs.teamInspirationFeed?.classList.toggle("hidden", !favoriteOnly) +} + function openTeamInspirationFeed() { toggleRailSection("gallery", true) - refs.teamInspirationFeed?.classList.remove("hidden") if (refs.galleryFavoriteOnlyInput) { refs.galleryFavoriteOnlyInput.checked = true } state.galleryFavoriteOnly = true + syncMyFavoritesSummary() void refreshGallery() refs.gallerySearchInput?.focus() } @@ -5028,10 +4969,10 @@ function teamChatReadPayload(room = state.teamChatRoom, messageId = state.teamCh } } -function teamChatSendPayload(content) { +function teamChatSendPayload(content, room = state.teamChatRoom) { return { - room_type: state.teamChatRoom.type || "team", - recipient_user_id: state.teamChatRoom.recipientUserId || null, + room_type: room.type || "team", + recipient_user_id: room.recipientUserId || null, content, } } @@ -5154,6 +5095,8 @@ function resetTeamChatState() { meta: "部门群", } state.teamChatLastMessageId = 0 + state.teamChatMessageRequestSeq += 1 + renderTeamChatMessages() clearTeamChatQuote() state.teamChatOpenMenuId = null updateTeamChatUnreadBadge(0) @@ -5424,6 +5367,21 @@ function updateTeamChatLastMessageId() { }, 0) } +function teamChatMessageRenderSignature(message = {}) { + const messageId = Number(message.id || 0) + const identity = messageId > 0 ? `id:${messageId}` : `client:${message.client_id || ""}` + return JSON.stringify([ + identity, + message.room_key || "", + Number(message.sender_user_id || 0), + message.sender_type || "", + message.sender_name || "", + message.content || "", + message.created_at || "", + Boolean(message.pending), + ]) +} + function mergeTeamChatMessages(incomingMessages = [], { replace = false } = {}) { const source = replace ? [] : state.teamChatMessages const byId = new Map() @@ -5447,7 +5405,7 @@ function mergeTeamChatMessages(incomingMessages = [], { replace = false } = {}) byClientId.set(message.client_id, message) } }) - state.teamChatMessages = [...Array.from(byId.values()), ...Array.from(byClientId.values())].sort((left, right) => { + const nextMessages = [...Array.from(byId.values()), ...Array.from(byClientId.values())].sort((left, right) => { const leftId = Number(left.id || 0) const rightId = Number(right.id || 0) if (leftId > 0 && rightId > 0) { @@ -5455,7 +5413,17 @@ function mergeTeamChatMessages(incomingMessages = [], { replace = false } = {}) } return String(left.created_at || "").localeCompare(String(right.created_at || "")) }) + const changed = nextMessages.length !== state.teamChatMessages.length + || nextMessages.some((message, index) => ( + teamChatMessageRenderSignature(message) + !== teamChatMessageRenderSignature(state.teamChatMessages[index]) + )) + if (!changed) { + return false + } + state.teamChatMessages = nextMessages updateTeamChatLastMessageId() + return true } function isOwnTeamChatMessage(message) { @@ -5826,14 +5794,20 @@ async function refreshTeamChatMessages({ append = false } = {}) { return false } const requestedRoomKey = currentTeamChatRoomKey() + const requestSeq = Number(state.teamChatMessageRequestSeq || 0) + 1 + state.teamChatMessageRequestSeq = requestSeq + const requestIsCurrent = () => ( + requestSeq === state.teamChatMessageRequestSeq + && requestedRoomKey === currentTeamChatRoomKey() + ) const params = teamChatRoomParams() if (append && state.teamChatLastMessageId) { params.set("after_id", String(state.teamChatLastMessageId)) } try { const { response, data } = await fetchJSON(`/api/team-chat/messages?${params.toString()}`, { cache: "no-store" }) - if (requestedRoomKey !== currentTeamChatRoomKey()) { - return true + if (!requestIsCurrent()) { + return false } if (!response.ok) { setTeamChatStatus(data.error || "消息读取失败", true) @@ -5842,14 +5816,18 @@ async function refreshTeamChatMessages({ append = false } = {}) { const messages = Array.isArray(data.messages) ? data.messages.filter((message) => !message.room_key || message.room_key === requestedRoomKey) : [] - mergeTeamChatMessages(messages, { replace: !append }) - renderTeamChatMessages({ scrollToBottom: messages.length > 0 || !append }) + const messagesChanged = mergeTeamChatMessages(messages, { replace: !append }) + if (messagesChanged) { + renderTeamChatMessages({ scrollToBottom: messages.length > 0 || !append }) + } if (!refs.teamChatModal?.classList.contains("hidden")) { await markCurrentTeamChatRead() } - return true + return requestIsCurrent() } catch { - setTeamChatStatus("消息读取失败", true) + if (requestIsCurrent()) { + setTeamChatStatus("消息读取失败", true) + } return false } } @@ -5915,6 +5893,7 @@ function stopTeamChatPolling() { } async function switchTeamChatRoom(nextRoom) { + state.teamChatMessageRequestSeq += 1 state.teamChatRoom = { type: nextRoom.type || "team", recipientUserId: nextRoom.recipientUserId || null, @@ -5986,6 +5965,9 @@ async function submitTeamChatMessage(event) { return } const outgoingContent = formatTeamChatOutgoingContent(content) + const outgoingRoom = { ...state.teamChatRoom } + const outgoingRoomKey = currentTeamChatRoomKey() + const outgoingRoomIsCurrent = () => outgoingRoomKey === currentTeamChatRoomKey() const draftContent = refs.teamChatMessageInput?.value || "" const draftQuote = state.teamChatQuotedMessage if (refs.teamChatMessageInput) { @@ -6000,19 +5982,30 @@ async function submitTeamChatMessage(event) { try { const { response, data } = await fetchJSON("/api/team-chat/messages", { method: "POST", - body: JSON.stringify(teamChatSendPayload(outgoingContent)), + body: JSON.stringify(teamChatSendPayload(outgoingContent, outgoingRoom)), }) if (!response.ok) { + if (!outgoingRoomIsCurrent()) { + return + } state.teamChatMessages = state.teamChatMessages.filter((message) => message.client_id !== optimistic.client_id) renderTeamChatMessages() restoreTeamChatDraft(draftContent, draftQuote) setTeamChatStatus(data.error || "发送失败", true) return } - const messages = Array.isArray(data.messages) ? data.messages : [] + if (!outgoingRoomIsCurrent()) { + return + } + const messages = Array.isArray(data.messages) + ? data.messages.filter((message) => !message.room_key || message.room_key === outgoingRoomKey) + : [] replaceOptimisticTeamChatMessage(optimistic.client_id, messages) renderTeamChatMessages() await markCurrentTeamChatRead() + if (!outgoingRoomIsCurrent()) { + return + } if (data.bot_reply_pending) { scheduleTeamChatFastPolling() void refreshTeamChatMessages({ append: true }) @@ -6021,6 +6014,9 @@ async function submitTeamChatMessage(event) { setTeamChatStatus("已发送") } } catch { + if (!outgoingRoomIsCurrent()) { + return + } state.teamChatMessages = state.teamChatMessages.filter((message) => message.client_id !== optimistic.client_id) renderTeamChatMessages() restoreTeamChatDraft(draftContent, draftQuote) @@ -6633,6 +6629,7 @@ function createWorkspaceSnapshot() { metaText: refs.resultMeta.textContent, timingText: refs.resultTiming.textContent, storageText: refs.resultStorage.textContent, + countNoticeText: refs.resultCountNoticeText?.textContent || "", sizeWarningText: refs.resultSizeWarningText?.textContent || "", labelText: refs.resultPreviewLabel.textContent, imageSrc: state.resultPreview?.src || refs.resultImage.getAttribute("src") || "", @@ -6849,6 +6846,7 @@ async function restoreWorkspaceState() { refs.resultMeta.textContent = result.metaText || "" refs.resultTiming.textContent = result.timingText || "" refs.resultStorage.textContent = result.storageText || "" + setResultCountNotice(result.countNoticeText || "") setResultSizeWarning(result.sizeWarningText || "") setDownloadAvailable( state.resultPreview.src, @@ -6911,6 +6909,12 @@ function cloneImageAsset(asset, overrides = {}) { return { ...asset, ...overrides } } +function imageAssetDimensions(asset) { + const width = Math.round(Number(asset?.width || asset?.saved_image_width || 0)) + const height = Math.round(Number(asset?.height || asset?.saved_image_height || 0)) + return width > 0 && height > 0 ? { width, height } : null +} + function modelInputAssetForLogoWorkflow(asset) { if (!asset?.logoOverlayApplied) { return asset @@ -7549,7 +7553,7 @@ async function confirmPromptBeforeRun(kind, text) { }, itinerary: { title: "生成路线图前确认提示词", - description: "请逐字检查行程日期、地点、酒店、交通和每日说明。", + description: "请核对标题、日期和每天的地点顺序。", }, edit: { title: "开始编辑前确认提示词", @@ -8897,6 +8901,7 @@ function clearResult() { refs.resultMeta.textContent = "" refs.resultTiming.textContent = "" refs.resultStorage.textContent = "" + setResultCountNotice("") setResultSizeWarning("") refs.logoComposeStatus.textContent = refs.logoOverlayEnabled?.checked ? "本地贴图" : "未启用" refs.rawResponseOutput.textContent = "{}" @@ -8956,6 +8961,7 @@ function snapshotCurrentResultState() { meta: refs.resultMeta.textContent, timing: refs.resultTiming.textContent, storage: refs.resultStorage.textContent, + countNoticeText: refs.resultCountNoticeText?.textContent || "", sizeWarningText: refs.resultSizeWarningText?.textContent || "", logoStatus: refs.logoComposeStatus.textContent, rawResponse: state.rawResponsePreview, @@ -9010,6 +9016,7 @@ function restoreResultStateSnapshot(snapshot) { refs.resultMeta.textContent = snapshot.meta || "" refs.resultTiming.textContent = snapshot.timing || "" refs.resultStorage.textContent = snapshot.storage || "" + setResultCountNotice(snapshot.countNoticeText || "") setResultSizeWarning(snapshot.sizeWarningText || "") refs.logoComposeStatus.textContent = snapshot.logoStatus || (refs.logoOverlayEnabled?.checked ? "本地贴图" : "未启用") @@ -9118,6 +9125,7 @@ function previewPendingResult({ mode, prompt, model, size, sourceName = "" }) { refs.resultMeta.textContent = metaParts.filter(Boolean).join(" · ") refs.resultTiming.textContent = "请求进行中 0.0s" refs.resultStorage.textContent = "" + setResultCountNotice("") setResultSizeWarning("") setDownloadDisabled() refs.shareResultPanel?.classList.add("hidden") @@ -9146,6 +9154,8 @@ function candidateAsset(candidate, payload, index) { return { name: candidate.saved_image_name || `picgen-${payload.mode}-${index + 1}-${Date.now()}.png`, type: candidate.saved_image_mime || (candidate.image_data_url ? inferMimeFromDataUrl(candidate.image_data_url) : ""), + width: candidate.saved_image_width || candidate.width || payload.saved_image_width || null, + height: candidate.saved_image_height || candidate.height || payload.saved_image_height || null, dataUrl: candidate.image_data_url || "", savedUrl: candidate.saved_image_url || "", savedPath: candidate.saved_image_path || "", @@ -9435,9 +9445,13 @@ function applyPrimaryResultCandidate(firstCandidate, payload, imageSource, durat ) } -async function composeLogoOverlayAfterDisplay(payload, durationMs, resultGenerationSeq) { +async function composeLogoOverlayAfterDisplay(payload, durationMs, resultGenerationSeq, sourceCanvasSize = null) { try { - const composedCandidates = await composeLogoOverlayForCandidates(state.resultCandidates, true) + const composedCandidates = await composeLogoOverlayForCandidates( + state.resultCandidates, + true, + sourceCanvasSize, + ) if (!composedCandidates.length || state.activeRequestController || resultGenerationSeq !== state.resultGenerationSeq) { return } @@ -9502,6 +9516,7 @@ async function setResult(payload, durationMs, requestSource = null) { setTextFidelityPanel("等待检查", "正在准备当前结果的文字一致性检查。", { candidateIndex: 0 }) setError("") const isTransformMode = ["edit", "variant", "reference"].includes(payload.mode) + const logoDetectionSourceSize = isTransformMode ? imageAssetDimensions(requestSource) : null refs.resultPreviewLabel.textContent = payload.mode === "variant" ? "延展后" : payload.mode === "edit" @@ -9612,6 +9627,7 @@ async function setResult(payload, durationMs, requestSource = null) { setStatusMessage(compositionMessage) } const sizeMismatchMessage = resolveSizeMismatchWarning(payload, actualSize) + setResultCountNotice(resolveResultCountNotice(payload, enrichedCandidates.length)) setResultSizeWarning(sizeMismatchMessage) state.rawResponsePreview = sanitizeRawResponse(payload.raw_response || {}) renderRawResponsePreview() @@ -9628,7 +9644,7 @@ async function setResult(payload, durationMs, requestSource = null) { refs.logoComposeStatus.textContent = payload.logo_requested ? "原图已显示,正在贴 LOGO" : "未启用" if (payload.logo_requested) { setDownloadPendingLogo() - void composeLogoOverlayAfterDisplay(payload, durationMs, resultGenerationSeq) + void composeLogoOverlayAfterDisplay(payload, durationMs, resultGenerationSeq, logoDetectionSourceSize) } const checks = [ checkCopyrightRisk(payload), @@ -10304,6 +10320,14 @@ function companyLogoCandidatePlacements(canvas, logoCanvas) { return candidates } +function companyLogoDetectionPlacements(canvas, logoCanvas, sourceCanvasSize = null) { + if (!sourceCanvasSize) { + return companyLogoCandidatePlacements(canvas, logoCanvas) + } + const sourcePlacements = companyLogoCandidatePlacements(sourceCanvasSize, logoCanvas) + return scaleLogoDetectionPlacements(sourcePlacements, sourceCanvasSize, canvas) +} + function calculateLogoPlacement(canvas, logoCanvas) { const candidates = companyLogoCandidatePlacements(canvas, logoCanvas) @@ -10356,13 +10380,13 @@ function resizeCanvasHighQuality(sourceCanvas, targetWidth, targetHeight) { return finalCanvas } -function findExistingOfficialLogo(canvas, logoCanvas) { +function findExistingOfficialLogo(canvas, logoCanvas, sourceCanvasSize = null) { const ctx = canvas.getContext("2d", { willReadFrequently: true }) if (!ctx) { return null } let bestMatch = null - for (const placement of companyLogoCandidatePlacements(canvas, logoCanvas)) { + for (const placement of companyLogoDetectionPlacements(canvas, logoCanvas, sourceCanvasSize)) { const scaledLogoCanvas = resizeCanvasHighQuality(logoCanvas, placement.width, placement.height) const scaledLogoCtx = scaledLogoCanvas.getContext("2d", { willReadFrequently: true }) if (!scaledLogoCtx) { @@ -10386,7 +10410,7 @@ function findExistingOfficialLogo(canvas, logoCanvas) { return bestMatch } -async function applyLogoOverlayToDataUrl(dataUrl) { +async function applyLogoOverlayToDataUrl(dataUrl, sourceCanvasSize = null) { if (!dataUrl) { return null } @@ -10406,7 +10430,7 @@ async function applyLogoOverlayToDataUrl(dataUrl) { } ctx.drawImage(baseImage, 0, 0, width, height) - const existingLogo = findExistingOfficialLogo(canvas, logoCanvas) + const existingLogo = findExistingOfficialLogo(canvas, logoCanvas, sourceCanvasSize) if (existingLogo) { return { dataUrl, @@ -10418,6 +10442,8 @@ async function applyLogoOverlayToDataUrl(dataUrl) { }, preserved: true, matchScore: existingLogo.score, + matchedPixels: existingLogo.matchedPixels, + comparedPixels: existingLogo.comparedPixels, } } const placement = calculateLogoPlacement(canvas, logoCanvas) @@ -10455,7 +10481,7 @@ async function persistFinalLogoImage(candidate, asset, composed, composedName) { return response.image || null } -async function composeLogoOverlayForCandidates(candidates, logoRequested) { +async function composeLogoOverlayForCandidates(candidates, logoRequested, sourceCanvasSize = null) { if (!logoRequested) { refs.logoComposeStatus.textContent = "未启用" return candidates @@ -10470,22 +10496,41 @@ async function composeLogoOverlayForCandidates(candidates, logoRequested) { }) const composedCandidates = [] - for (const candidate of candidates) { + for (const [candidateIndex, candidate] of candidates.entries()) { const asset = candidate.asset if (!asset) { composedCandidates.push(candidate) continue } const sourceDataUrl = asset.dataUrl || await ensureAssetDataUrl(asset) - const composed = await applyLogoOverlayToDataUrl(sourceDataUrl) + const composed = await applyLogoOverlayToDataUrl(sourceDataUrl, sourceCanvasSize) if (!composed) { composedCandidates.push(candidate) continue } if (composed.preserved) { + const logoPreservation = createLogoPreservationDiagnostic( + { + score: composed.matchScore, + matchedPixels: composed.matchedPixels, + comparedPixels: composed.comparedPixels, + }, + COMPANY_LOGO_EXISTING_MATCH_THRESHOLD, + { + generatedImageId: candidate.generated_image_id || asset.generatedImageId, + candidateIndex, + savedImagePath: candidate.saved_image_path || asset.savedPath, + }, + ) appendDebugLine("已有官方 LOGO,保留原位置且不重复贴入", { - placement: composed.placement, - matchScore: composed.matchScore, + generatedImageId: logoPreservation.generated_image_id || null, + candidateIndex: logoPreservation.candidate_index, + savedImagePath: logoPreservation.saved_image_path || "", + placement: `${composed.placement.x},${composed.placement.y},${composed.placement.width}x${composed.placement.height}`, + matchRate: logoPreservation.match_rate, + matchedPixels: logoPreservation.matched_pixels, + comparedPixels: logoPreservation.compared_pixels, + basis: logoPreservation.basis, }) const preservedAsset = { ...asset, @@ -10501,6 +10546,10 @@ async function composeLogoOverlayForCandidates(candidates, logoRequested) { logo_overlay_source: COMPANY_LOGO_NAME, logo_text_color: composed.placement.textColor || "original", logo_final_persisted: Boolean(candidate.saved_image_url || asset.savedUrl), + metadata: { + ...(candidate.metadata && typeof candidate.metadata === "object" ? candidate.metadata : {}), + logo_preservation: logoPreservation, + }, asset: preservedAsset, }) continue @@ -10588,8 +10637,18 @@ async function downscaleDataUrlForRisk(dataUrl, maxSide = 768, quality = 0.82) { return canvas.toDataURL("image/jpeg", quality) } +async function ensureAssetDimensions(asset, dataUrl) { + if (!asset || imageAssetDimensions(asset) || !dataUrl) { + return + } + const image = await loadImageElement(dataUrl, "无法读取输入图片尺寸。") + asset.width = image.naturalWidth || image.width + asset.height = image.naturalHeight || image.height +} + async function ensureAssetDataUrl(asset) { if (asset?.dataUrl) { + await ensureAssetDimensions(asset, asset.dataUrl) return asset.dataUrl } @@ -10607,6 +10666,7 @@ async function ensureAssetDataUrl(asset) { const dataUrl = await blobToDataURL(blob) asset.dataUrl = dataUrl asset.type = asset.type || blob.type || inferMimeFromDataUrl(dataUrl) + await ensureAssetDimensions(asset, dataUrl) return dataUrl } @@ -10990,7 +11050,15 @@ async function submitGenerate() { userContextEpoch, }) ensureUserContextCurrent(userContextEpoch) - await setResult({ ...result, mode: "reference", prompt, size, logo_requested: logoRequested, text_contract: textContract }, performance.now() - startedAt, requestSources.at(-1)) + await setResult({ + ...result, + mode: "reference", + prompt, + size, + logo_requested: logoRequested, + requested_sample_count: referenceSampleCount, + text_contract: textContract, + }, performance.now() - startedAt, requestSources.at(-1)) rememberRegenerationRequest("generate", requestSnapshot) pushHistory({ mode: "reference", @@ -11032,7 +11100,12 @@ async function submitGenerate() { userContextEpoch, }) ensureUserContextCurrent(userContextEpoch) - await setResult({ ...result, logo_requested: logoRequested, text_contract: textContract }, performance.now() - startedAt) + await setResult({ + ...result, + logo_requested: logoRequested, + requested_sample_count: sampleCount, + text_contract: textContract, + }, performance.now() - startedAt) rememberRegenerationRequest("generate", requestSnapshot) pushHistory({ mode: "generate", @@ -11719,7 +11792,10 @@ function bindEvents() { window.clearTimeout(state.gallerySearchTimer) state.gallerySearchTimer = window.setTimeout(refreshGallery, 250) }) - refs.galleryFavoriteOnlyInput?.addEventListener("change", refreshGallery) + refs.galleryFavoriteOnlyInput?.addEventListener("change", () => { + syncMyFavoritesSummary() + void refreshGallery() + }) refs.clearGalleryFiltersButton?.addEventListener("click", () => { if (refs.gallerySearchInput) { refs.gallerySearchInput.value = "" @@ -11729,6 +11805,7 @@ function bindEvents() { } state.gallerySearch = "" state.galleryFavoriteOnly = false + syncMyFavoritesSummary() void refreshGallery() }) refs.saveGalleryMetaButton?.addEventListener("click", saveGalleryMeta) diff --git a/static/index.html b/static/index.html index df76261..d69dc91 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ PicGen Console - +
@@ -149,7 +149,7 @@

收到分享

作品库

- +
@@ -160,8 +160,8 @@

作品库

还没有作品。生成成功后会自动出现在这里。

@@ -704,6 +704,10 @@

结果对比

点击放大 +
- PicGen Console v0.1.65 + PicGen Console v0.1.66 本地代理保存结果到 data/outputs
@@ -1371,6 +1375,6 @@

图片预览

- + diff --git a/static/logo-placement.mjs b/static/logo-placement.mjs index e8e541b..f504f7b 100644 --- a/static/logo-placement.mjs +++ b/static/logo-placement.mjs @@ -16,6 +16,53 @@ function toPositiveInteger(value) { return Math.max(1, Math.floor(Number(value) || 0)) } +function finitePositiveNumber(value) { + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : 0 +} + +export function scaleLogoDetectionPlacements(placements, sourceCanvas, resultCanvas) { + const rows = Array.isArray(placements) ? placements : [] + const sourceWidth = finitePositiveNumber(sourceCanvas?.width) + const resultWidth = finitePositiveNumber(resultCanvas?.width) + if (!sourceWidth || !resultWidth) { + return rows.map((placement) => ({ ...placement })) + } + + const scale = resultWidth / sourceWidth + return rows.map((placement) => ({ + x: Math.round((Number(placement?.x) || 0) * scale), + y: Math.round((Number(placement?.y) || 0) * scale), + width: Math.max(1, Math.round((Number(placement?.width) || 0) * scale)), + height: Math.max(1, Math.round((Number(placement?.height) || 0) * scale)), + })) +} + +export function createLogoPreservationDiagnostic(match, threshold, context = {}) { + const score = Math.min(1, Math.max(0, Number(match?.score) || 0)) + const generatedImageId = Math.max(0, Math.floor(Number(context?.generatedImageId) || 0)) + const rawCandidateIndex = Number(context?.candidateIndex) + const savedImagePath = String(context?.savedImagePath || "").trim().slice(0, 1024) + const diagnostic = { + decision: "preserve", + match_rate: Number(score.toFixed(4)), + matched_pixels: Math.max(0, Math.floor(Number(match?.matchedPixels) || 0)), + compared_pixels: Math.max(0, Math.floor(Number(match?.comparedPixels) || 0)), + threshold: Math.min(1, Math.max(0, Number(threshold) || 0)), + basis: "official_logo_pixel_match", + } + if (generatedImageId > 0) { + diagnostic.generated_image_id = generatedImageId + } + if (Number.isFinite(rawCandidateIndex) && rawCandidateIndex >= 0) { + diagnostic.candidate_index = Math.floor(rawCandidateIndex) + } + if (savedImagePath) { + diagnostic.saved_image_path = savedImagePath + } + return diagnostic +} + function normalizeRegion(ctx, region) { const canvasWidth = toPositiveInteger(ctx.canvas.width) const canvasHeight = toPositiveInteger(ctx.canvas.height) diff --git a/static/styles.css b/static/styles.css index 8c43252..d93d057 100644 --- a/static/styles.css +++ b/static/styles.css @@ -6227,3 +6227,146 @@ body.ui-simple-mode .toast-message .ux-icon { padding: 8px 12px; } } + +/* C6 simple shell: start */ +body.ui-simple-mode .app-topbar { + gap: var(--ux-space-4); + padding: var(--ux-space-3) var(--ux-space-4); + border-color: var(--ux-border-2); + background: var(--ux-bg-2); + backdrop-filter: var(--ux-shadow-none); +} + +body.ui-simple-mode .topbar-tools { + gap: var(--ux-space-2); +} + +body.ui-simple-mode .topbar-tools > .ghost-button, +body.ui-simple-mode .topbar-tools > .system-pill { + min-height: var(--ux-control-height); + padding: var(--ux-space-0) var(--ux-control-padding-x); + border-color: var(--ux-border-2); + border-radius: var(--ux-radius-control); + background: var(--ux-fill-2); + box-shadow: var(--ux-shadow-none); + color: var(--ux-text-2); + font-family: var(--ux-font-family); + font-size: var(--ux-font-size-body); + font-weight: var(--ux-font-weight-medium); + line-height: var(--ux-line-height-body); + transition: var(--ux-transition-control); +} + +body.ui-simple-mode .topbar-tools > .ghost-button:hover:not(:disabled), +body.ui-simple-mode .topbar-tools > button.system-pill:hover:not(:disabled) { + border-color: var(--ux-border-3); + background: var(--ux-fill-3); + color: var(--ux-text-1); +} + +body.ui-simple-mode .topbar-tools > .ghost-button:active:not(:disabled), +body.ui-simple-mode .topbar-tools > button.system-pill:active:not(:disabled) { + border-color: var(--ux-border-3); + background: var(--ux-fill-4); + color: var(--ux-text-1); + transition: var(--ux-shadow-none); +} + +body.ui-simple-mode .topbar-tools > .ghost-button:focus-visible, +body.ui-simple-mode .topbar-tools > button.system-pill:focus-visible { + outline: var(--ux-shadow-none); + box-shadow: var(--ux-focus-ring-primary); +} + +body.ui-simple-mode .topbar-tools > .system-pill:last-child { + border-color: var(--ux-primary-2); + border-radius: var(--ux-radius-small); + background: var(--ux-success-light); + color: var(--ux-success-text); +} + +body.ui-simple-mode .live-dot { + width: var(--ux-space-2); + height: var(--ux-space-2); + border-radius: var(--ux-radius-circle); + background: var(--ux-success); + box-shadow: var(--ux-badge-edge); +} + +body.ui-simple-mode .team-chat-button.has-unread { + border-color: var(--ux-primary-3); + background: var(--ux-primary-1); + color: var(--ux-primary-8); + animation: var(--ux-shadow-none); +} + +body.ui-simple-mode #resultPanel { + padding: var(--ux-space-0); + border-color: var(--ux-color-transparent); + border-radius: var(--ux-radius-none); + background: var(--ux-color-transparent); + box-shadow: var(--ux-shadow-none); +} + +body.ui-simple-mode #resultPanel .canvas-header { + padding: var(--ux-space-2) var(--ux-space-0) var(--ux-space-4); +} + +body.ui-simple-mode .status-badge { + min-height: var(--ux-control-height-small); + padding: var(--ux-space-0) var(--ux-space-2); + border: var(--ux-border-width) var(--ux-border-style) var(--ux-primary-2); + border-radius: var(--ux-radius-small); + background: var(--ux-success-light); + color: var(--ux-success-text); + font-size: var(--ux-font-size-body-small); + font-weight: var(--ux-font-weight-medium); + line-height: var(--ux-line-height-body-small); +} + +body.ui-simple-mode .status-badge.working { + border-color: var(--ux-warning-hover); + background: var(--ux-warning-light); + color: var(--ux-warning-text); + animation: var(--ux-shadow-none); +} + +body.ui-simple-mode .preview-card { + border-color: var(--ux-border-2); + border-radius: var(--ux-radius-card); + background: var(--ux-bg-2); + box-shadow: var(--ux-shadow-card-rest); +} + +body.ui-simple-mode .preview-label { + border-radius: var(--ux-radius-small); + background: var(--ux-text-2); + color: var(--ux-color-white); + font-weight: var(--ux-font-weight-medium); +} + +body.ui-simple-mode .preview-frame { + background: var(--ux-fill-1); +} + +body.ui-simple-mode .preview-frame:focus-visible { + outline: var(--ux-shadow-none); + box-shadow: var(--ux-focus-ring-primary); +} + +body.ui-simple-mode .system-footer { + gap: var(--ux-space-6); + padding: var(--ux-space-0) var(--ux-space-4); + border-color: var(--ux-border-2); + background: var(--ux-bg-2); + color: var(--ux-text-3); + font-family: var(--ux-font-family); + font-size: var(--ux-font-size-caption); + line-height: var(--ux-line-height-caption); +} + +body.ui-simple-mode .system-footer strong { + color: var(--ux-text-2); + font-weight: var(--ux-font-weight-medium); +} +/* C6 simple shell: end */ diff --git a/tests/test_api.py b/tests/test_api.py index cc554d5..1f46ef1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import json import math @@ -43,6 +44,22 @@ def valid_png_b64(width: int, height: int, color: tuple[int, int, int] = (60, 12 return base64.b64encode(output.getvalue()).decode("ascii") +def _record_itinerary_render_event_loop_calls(monkeypatch) -> list[bool]: + rendered_on_event_loop: list[bool] = [] + + def render(*args, **kwargs): + try: + asyncio.get_running_loop() + except RuntimeError: + rendered_on_event_loop.append(False) + else: + rendered_on_event_loop.append(True) + return render_itinerary_map_svg(*args, **kwargs) + + monkeypatch.setattr("picgen.routes.render_itinerary_map_svg", render) + return rendered_on_event_loop + + def test_health_endpoint_reports_ok(make_client): client, _, _ = make_client() response = client.get("/api/health") @@ -1037,13 +1054,16 @@ def test_itinerary_map_plan_requires_coordinates_before_rendering(make_client, s assert "缺少坐标" in payload["warnings"][0] -def test_itinerary_map_render_saves_integrated_ai_artwork_with_geometry_control(make_client, settings_factory): +def test_itinerary_map_render_saves_integrated_ai_artwork_with_geometry_control( + make_client, settings_factory, monkeypatch +): settings = settings_factory( auth_enabled=True, admin_password="correct horse battery admin", default_api_key="sk-test", ) client, fake, resolved = make_client(settings=settings) + rendered_on_event_loop = _record_itinerary_render_event_loop_calls(monkeypatch) (resolved.static_dir / "6renyou.png").write_bytes(base64.b64decode(TINY_PNG_B64)) fake.run_responses.return_value = {"data": [{"b64_json": TINY_PNG_B64}], "created": 1} fake.run_file_upload.return_value = {"id": "file_itinerary_control"} @@ -1144,6 +1164,7 @@ def test_itinerary_map_render_saves_integrated_ai_artwork_with_geometry_control( file_response = client.get(payload["saved_image_url"]) assert file_response.status_code == 200 assert file_response.text.startswith(" str: + return im.render_itinerary_map_svg( + { + "title": title, + "subtitle": subtitle, + "stops": [ + {"name": "巴黎", "lat": 48.8566, "lng": 2.3522, "status": "ok"}, + {"name": "罗马", "lat": 41.9028, "lng": 12.4964, "status": "ok"}, + ], + } + ) + + +def test_title_font_subsetting_continues_after_non_oserror(monkeypatch) -> None: + broken_dir = Path("/broken-static") + working_dir = Path("/working-static") + calls: list[Path] = [] + + def subset(font_path: Path, _glyphs: str) -> bytes: + calls.append(font_path) + if font_path.is_relative_to(broken_dir): + raise ValueError("invalid font") + return b"fallback-font" + + im._embedded_title_font_face_css_cached.cache_clear() + monkeypatch.setattr(im, "_candidate_static_dirs", lambda: [broken_dir, working_dir]) + monkeypatch.setattr(im, "_subset_title_font_bytes", subset) + + try: + css = im._embedded_title_font_face_css("欧洲行程") + finally: + im._embedded_title_font_face_css_cached.cache_clear() + + assert "data:font/ttf;base64," in css + assert calls == [ + broken_dir / im.TITLE_FONT_RELATIVE_PATH, + working_dir / im.TITLE_FONT_RELATIVE_PATH, + ] + + +def test_failed_title_subset_does_not_evict_other_cached_titles(monkeypatch) -> None: + calls: list[str] = [] + + def subset(_font_path: Path, glyphs: str) -> bytes: + calls.append(glyphs) + if glyphs == "乙": + raise OSError("temporarily unavailable") + return f"font-{glyphs}".encode() + + im._embedded_title_font_face_css_cached.cache_clear() + monkeypatch.setattr(im, "_candidate_static_dirs", lambda: [Path("/static")]) + monkeypatch.setattr(im, "_subset_title_font_bytes", subset) + + try: + first_css = im._embedded_title_font_face_css("甲") + assert im._embedded_title_font_face_css("乙") == "" + assert im._embedded_title_font_face_css("乙") == "" + assert im._embedded_title_font_face_css("甲") == first_css + finally: + im._embedded_title_font_face_css_cached.cache_clear() + + assert calls.count("甲") == 1 + assert calls.count("乙") == 2 + + +@pytest.mark.parametrize( + ("title", "font_size"), + [ + ("甲" * 8, 80), + ("甲" * 9, 64), + ("甲" * 11, 52), + ], +) +def test_itinerary_title_font_size_uses_character_tiers(title: str, font_size: int) -> None: + svg = _render_heading(title=title) + + assert f".title{{font-size:{font_size}px" in svg + + +@pytest.mark.parametrize( + ("subtitle", "font_size"), + [ + ("乙" * 20, 32), + ("乙" * 21, 26), + ("乙" * 25, 21), + ], +) +def test_itinerary_subtitle_font_size_uses_character_tiers(subtitle: str, font_size: int) -> None: + svg = _render_heading(title="欧洲旅行", subtitle=subtitle) + + assert f".subtitle{{font-size:{font_size}px" in svg + + +def test_itinerary_heading_truncates_after_smallest_font_tier() -> None: + svg = _render_heading(title="甲" * 13, subtitle="乙" * 31) + + assert f">{'甲' * 11}…" in svg + assert f">{'乙' * 29}…" in svg + assert "甲" * 13 not in svg + assert "乙" * 31 not in svg + + def test_country_label_resolves_border_cities_by_nearest_box_center(): # Overlapping bounding boxes used to mislabel border cities (first-match-wins). assert im._country_label_from_coordinates(43.70, 7.27) == "法国" # Nice diff --git a/tests/test_logo_placement.py b/tests/test_logo_placement.py index 84cc994..e55a048 100644 --- a/tests/test_logo_placement.py +++ b/tests/test_logo_placement.py @@ -1,9 +1,12 @@ from __future__ import annotations +import base64 import json import subprocess from pathlib import Path +from PIL import Image + ROOT_DIR = Path(__file__).resolve().parents[1] @@ -15,7 +18,9 @@ def _run_logo_policy(expression: str) -> object: calculateRegionComplexity, calculateRegionTextEdgePenalty, chooseLogoPlacement, + createLogoPreservationDiagnostic, expandLogoSafetyRegion, + scaleLogoDetectionPlacements, }} from './static/logo-placement.mjs' console.log(JSON.stringify({expression})) """ @@ -88,6 +93,91 @@ def test_official_logo_pixel_match_accepts_small_resampling_shift() -> None: assert result == {"score": 1, "matchedPixels": 9, "comparedPixels": 9} +def test_logo_detection_scales_real_official_logo_with_result_canvas() -> None: + source_size = (1536, 2048) + result_size = (1024, 1365) + source_placement = {"x": 42, "y": 42, "width": 220, "height": 68} + expected_placement = {"x": 28, "y": 28, "width": 147, "height": 45} + + with Image.open(ROOT_DIR / "static" / "6renyou.png") as source_logo: + logo = source_logo.convert("RGBA") + alpha_bounds = logo.getchannel("A").getbbox() + assert alpha_bounds is not None + logo = logo.crop(alpha_bounds) + scaled_source_logo = logo.resize( + (source_placement["width"], source_placement["height"]), + Image.Resampling.LANCZOS, + ) + source_canvas = Image.new("RGBA", source_size, (232, 236, 234, 255)) + source_canvas.alpha_composite(scaled_source_logo, (source_placement["x"], source_placement["y"])) + result_canvas = source_canvas.resize(result_size, Image.Resampling.LANCZOS) + result_region = result_canvas.crop( + ( + expected_placement["x"], + expected_placement["y"], + expected_placement["x"] + expected_placement["width"], + expected_placement["y"] + expected_placement["height"], + ) + ) + expected_logo = logo.resize( + (expected_placement["width"], expected_placement["height"]), + Image.Resampling.LANCZOS, + ) + + result_region_b64 = base64.b64encode(result_region.tobytes()).decode("ascii") + expected_logo_b64 = base64.b64encode(expected_logo.tobytes()).decode("ascii") + result = _run_logo_policy( + "(() => {" + f"const placements = scaleLogoDetectionPlacements([{json.dumps(source_placement)}], " + f"{{ width: {source_size[0]}, height: {source_size[1]} }}, " + f"{{ width: {result_size[0]}, height: {result_size[1]} }});" + "const placement = placements[0];" + f"const base = Uint8ClampedArray.from(Buffer.from('{result_region_b64}', 'base64'));" + f"const logo = Uint8ClampedArray.from(Buffer.from('{expected_logo_b64}', 'base64'));" + "return { placement, match: calculateOfficialLogoPixelMatch(" + "{ data: base, width: placement.width, height: placement.height }," + "{ data: logo, width: placement.width, height: placement.height }," + ") };" + "})()" + ) + + assert result["placement"] == expected_placement + assert result["match"]["comparedPixels"] >= 128 + assert result["match"]["score"] >= 0.9 + + +def test_logo_detection_uses_width_ratio_for_nonuniform_canvas_drift() -> None: + result = _run_logo_policy( + "scaleLogoDetectionPlacements(" + "[{ x: 42, y: 42, width: 220, height: 68 }], " + "{ width: 1536, height: 2048 }, " + "{ width: 1024, height: 1200 })" + ) + + assert result == [{"x": 28, "y": 28, "width": 147, "height": 45}] + + +def test_logo_preservation_diagnostic_contains_only_safe_decision_evidence() -> None: + result = _run_logo_policy( + "createLogoPreservationDiagnostic(" + "{ score: 0.934567, matchedPixels: 842, comparedPixels: 901 }, 0.9, " + "{ generatedImageId: 42, candidateIndex: 2, savedImagePath: 'outputs/20260712/result.png' })" + ) + + assert result == { + "decision": "preserve", + "match_rate": 0.9346, + "matched_pixels": 842, + "compared_pixels": 901, + "threshold": 0.9, + "basis": "official_logo_pixel_match", + "generated_image_id": 42, + "candidate_index": 2, + "saved_image_path": "outputs/20260712/result.png", + } + assert not {"api_key", "token", "password", "image_data"}.intersection(result) + + def test_official_logo_pixel_match_rejects_matching_colors_outside_local_radius() -> None: result = _run_logo_policy( "(() => {" diff --git a/tests/test_simple_shell_styles.py b/tests/test_simple_shell_styles.py new file mode 100644 index 0000000..e23f1b5 --- /dev/null +++ b/tests/test_simple_shell_styles.py @@ -0,0 +1,65 @@ +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STYLES = ROOT / "static" / "styles.css" +START_MARKER = "/* C6 simple shell: start */" +END_MARKER = "/* C6 simple shell: end */" + + +def _shell_styles() -> str: + css = STYLES.read_text(encoding="utf-8") + assert START_MARKER in css + assert END_MARKER in css + return css.split(START_MARKER, 1)[1].split(END_MARKER, 1)[0] + + +def _declarations(css: str, selector: str) -> str: + match = re.search(rf"{re.escape(selector)}\s*\{{(?P[^}}]+)\}}", css) + assert match is not None, f"missing selector: {selector}" + return match.group("body") + + +def test_c6_shell_styles_are_scoped_to_simple_mode_and_use_ux_tokens() -> None: + css = _shell_styles() + + selectors = re.findall(r"(?:^|\})\s*([^{}]+)\{", css) + assert selectors + for selector_group in selectors: + selectors_in_group = [selector.strip() for selector in selector_group.split(",")] + assert all(selector.startswith("body.ui-simple-mode ") for selector in selectors_in_group) + + assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|\b\d+(?:\.\d+)?px\b", css) + assert not re.search(r"var\(--(?:line|green|muted|panel|radius|shadow|text)\b", css) + + +def test_c6_shell_covers_topbar_status_preview_and_footer() -> None: + css = _shell_styles() + + topbar = _declarations(css, "body.ui-simple-mode .app-topbar") + assert "border-color: var(--ux-border-2);" in topbar + assert "background: var(--ux-bg-2);" in topbar + + controls = _declarations( + css, + "body.ui-simple-mode .topbar-tools > .ghost-button,\nbody.ui-simple-mode .topbar-tools > .system-pill", + ) + assert "min-height: var(--ux-control-height);" in controls + assert "border-radius: var(--ux-radius-control);" in controls + assert "font-weight: var(--ux-font-weight-medium);" in controls + + status = _declarations(css, "body.ui-simple-mode .status-badge") + assert "background: var(--ux-success-light);" in status + assert "color: var(--ux-success-text);" in status + + result_panel = _declarations(css, "body.ui-simple-mode #resultPanel") + assert "border-color: var(--ux-color-transparent);" in result_panel + assert "box-shadow: var(--ux-shadow-none);" in result_panel + + preview_card = _declarations(css, "body.ui-simple-mode .preview-card") + assert "border-color: var(--ux-border-2);" in preview_card + assert "box-shadow: var(--ux-shadow-card-rest);" in preview_card + + footer = _declarations(css, "body.ui-simple-mode .system-footer") + assert "border-color: var(--ux-border-2);" in footer + assert "color: var(--ux-text-3);" in footer diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index cb201a9..0a41a42 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.65"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.66"' 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,13 +38,20 @@ 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.65"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.66"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "calculateOfficialLogoPixelMatch" in app_js + assert "scaleLogoDetectionPlacements" in app_js + assert "createLogoPreservationDiagnostic" in app_js assert "findExistingOfficialLogo" in app_js assert "composed.preserved" in app_js assert "已有官方 LOGO,保留原位置且不重复贴入" in app_js + assert "logo_preservation" in app_js + assert "match_rate" in app_js + assert 'basis: "official_logo_pixel_match"' in (ROOT_DIR / "static" / "logo-placement.mjs").read_text( + encoding="utf-8" + ) compose = app_js[ app_js.index("async function composeLogoOverlayForCandidates") : app_js.index("async function downscaleDataUrlForRisk") @@ -631,7 +638,7 @@ def test_reference_generation_keeps_source_lineage_when_reference_is_generated_a submit_start = app_js.index("async function submitGenerate") reference_start = app_js.index("const requestParts = referenceParts", submit_start) - reference_end = app_js.index('await setResult({ ...result, mode: "reference"', reference_start) + reference_end = app_js.index(" await setResult({", reference_start) reference_block = app_js[reference_start:reference_end] assert "const referenceLineageSource = requestSources.at(-1)" in reference_block assert "const sourceGeneratedImageId = referenceLineageSource?.generatedImageId || null" in reference_block @@ -658,7 +665,7 @@ def test_image_centric_workspace_actions_and_brand_download_gateway_are_present( assert ".result-frame:hover .result-hover-actions" in styles_css -def test_progress_overlay_and_team_inspiration_feed_are_visible_workflows() -> None: +def test_progress_overlay_and_my_favorites_are_visible_workflows() -> 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") @@ -671,12 +678,46 @@ def test_progress_overlay_and_team_inspiration_feed_are_visible_workflows() -> N assert "updateGenerationOverlay" in app_js assert "后台如遇临时错误会自动重试" in app_js assert 'id="teamInspirationFeedButton"' in index_html - assert "团队灵感流" in index_html + assert "我的收藏" in index_html + assert "团队灵感流" not in index_html assert "openTeamInspirationFeed" in app_js assert ".generation-overlay-steps" in styles_css assert ".team-feed-entry" in styles_css +def test_my_favorites_summary_tracks_the_favorite_only_filter() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + function_source = app_js[ + app_js.index("function syncMyFavoritesSummary") : app_js.index("function openTeamInspirationFeed") + ] + script = f""" +let hidden = null; +const refs = {{ + galleryFavoriteOnlyInput: {{ checked: true }}, + teamInspirationFeed: {{ classList: {{ toggle: (name, value) => {{ hidden = value; }} }} }}, +}}; +{function_source} +syncMyFavoritesSummary(); +const favoriteOnlyHidden = hidden; +refs.galleryFavoriteOnlyInput.checked = false; +syncMyFavoritesSummary(); +console.log(JSON.stringify({{ favoriteOnlyHidden, allWorksHidden: hidden }})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "favoriteOnlyHidden": False, + "allWorksHidden": True, + } + assert "syncMyFavoritesSummary()\n void refreshGallery()" in app_js + + def test_mobile_rail_sections_are_collapsible() -> 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") @@ -757,7 +798,7 @@ def test_itinerary_map_mode_renders_real_route_map_with_logo_safe_area() -> None assert 'itineraryPanel: document.querySelector("#itineraryPanel")' in app_js assert 'itineraryDescriptionInput: document.querySelector("#itineraryDescriptionInput")' in app_js - assert "buildAIItineraryMapPrompt" in app_js + assert "buildAIItineraryMapPrompt" not in app_js assert "parseItineraryCoordinateStops" in app_js assert "parseItineraryTextStops" in app_js assert "isItineraryInstructionSection" in app_js @@ -765,12 +806,10 @@ def test_itinerary_map_mode_renders_real_route_map_with_logo_safe_area() -> None assert "cleanItineraryStopName" in app_js assert 'postJSON("api/itinerary-map/render"' in app_js assert "itinerary_coordinates_required" in app_js - assert "isCompleteItineraryPrompt" in app_js - assert "normalizeCompleteItineraryPrompt" in app_js - assert "ITINERARY_GEOGRAPHY_GUARD" in app_js - assert "完整行程地图 prompt" in app_js - assert "return normalizeCompleteItineraryPrompt(normalizedDescription" in app_js - assert "stripCodeFence" in app_js + assert "isCompleteItineraryPrompt" not in app_js + assert "normalizeCompleteItineraryPrompt" not in app_js + assert "ITINERARY_GEOGRAPHY_GUARD" not in app_js + assert "stripCodeFence" not in app_js assert "submitAIItineraryMap" in app_js assert "AI_ITINERARY_EXAMPLE" in app_js assert "DETAILED_ITINERARY_TEMPLATE" in app_js @@ -798,13 +837,8 @@ def test_itinerary_map_mode_renders_real_route_map_with_logo_safe_area() -> None assert "applyDetailedItineraryTemplate" in app_js assert "shouldUseXinjiangRouteGuard" not in app_js assert "xinjiangRouteGuardPrompt" not in app_js - assert "drawItineraryLogoSafeArea" in app_js + assert "drawItineraryLogoSafeArea" not in app_js assert "withLogoLayoutPrompt(aiPrompt, logoRequested)" not in app_js - assert "地理正确性硬性要求" in app_js - assert "不能为了画面好看而调整地点相对位置" in app_js - assert "不要凭想象补地图" in app_js - assert "测绘式真实地图" in app_js - assert "跨大区或跨国家转场必须使用总览图、局部放大框或飞行连接" in app_js assert "库尔德宁" not in app_js assert "喀拉峻" not in app_js assert "伊宁" not in app_js @@ -812,9 +846,6 @@ def test_itinerary_map_mode_renders_real_route_map_with_logo_safe_area() -> None assert "赛里木湖" not in app_js assert "喀什" not in app_js assert "日期必须逐日出现" in app_js - assert "5/18" in app_js - assert "每两个连续地点之间必须有路线连接" in app_js - assert "必须标注大致距离或飞行/转场说明" in app_js assert "交通工具图标" in app_js assert "水彩漫画路线图" in app_js assert "红色粗路线" in app_js @@ -845,7 +876,6 @@ def test_itinerary_map_mode_renders_real_route_map_with_logo_safe_area() -> None assert "if (!routeStops.length)" in itinerary_submit_block assert "请填写副标题日期" in itinerary_submit_block assert "行程描述不能为空" in app_js - assert "不要让 AI 绘制或改造 6 人游 LOGO" in app_js assert "不要画 LOGO 占位框" in app_js assert "不要画边框" in app_js assert "不要画白底底板" in app_js @@ -951,6 +981,8 @@ def test_prompt_confirmation_modal_blocks_generation_until_checked() -> None: assert "await confirmPromptBeforeRun" in app_js assert "生成海报前确认提示词" in app_js assert "生成路线图前确认提示词" in app_js + assert "请核对标题、日期和每天的地点顺序。" in app_js + assert "请逐字检查行程日期、地点、酒店、交通和每日说明。" not in app_js assert "开始编辑前确认提示词" in app_js assert ".prompt-confirm-modal" in styles_css assert ".prompt-confirm-dialog" in styles_css @@ -1057,14 +1089,385 @@ def test_frontend_guards_async_logo_and_team_chat_room_races() -> None: app_js.index("async function refreshTeamChatMessages") : app_js.index("async function markCurrentTeamChatRead") ] logout_block = app_js[app_js.index("async function logout") : app_js.index("async function loadWorkspaceSnapshot")] + reset_chat_block = app_js[ + app_js.index("function resetTeamChatState") : app_js.index("function renderTeamChatMemberAvatar") + ] assert "resultGenerationSeq !== state.resultGenerationSeq" in logo_block assert "const resultGenerationSeq = state.resultGenerationSeq + 1" in app_js assert "const requestedRoomKey = currentTeamChatRoomKey()" in chat_block - assert "requestedRoomKey !== currentTeamChatRoomKey()" in chat_block + assert "requestedRoomKey === currentTeamChatRoomKey()" in chat_block + assert "requestSeq === state.teamChatMessageRequestSeq" in chat_block assert "message.room_key === requestedRoomKey" in chat_block assert "window.clearTimeout(state.persistTimer)" in logout_block assert "state.persistTimer = null" in logout_block + assert "renderTeamChatMessages()" in reset_chat_block + + +def test_responses_shortfall_notice_reports_requested_and_returned_counts() -> 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") + function_source = app_js[ + app_js.index("function resolveResultCountNotice") : app_js.index("function setResultCountNotice") + ] + expression = """ +console.log(JSON.stringify({ + short: resolveResultCountNotice({ transport: "responses-image", requested_sample_count: 3 }, 1), + complete: resolveResultCountNotice({ transport: "responses-image", requested_sample_count: 3 }, 3), + imagesApi: resolveResultCountNotice({ transport: "images-generate", requested_sample_count: 3 }, 1), +})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", f"{function_source}\n{expression}"], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + result = json.loads(completed.stdout) + + assert result == { + "short": "本次请求 3 张,上游返回 1 张", + "complete": "", + "imagesApi": "", + } + assert 'id="resultCountNotice"' in index_html + assert 'id="resultCountNoticeText"' in index_html + assert "setResultCountNotice(resolveResultCountNotice(payload, enrichedCandidates.length))" in app_js + + +def test_team_chat_poll_renders_only_when_messages_change() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + room_params_source = app_js[ + app_js.index("function teamChatRoomParams") : app_js.index("function teamChatReadPayload") + ] + merge_source = app_js[ + app_js.index("function updateTeamChatLastMessageId") : app_js.index("function isOwnTeamChatMessage") + ] + room_key_source = app_js[ + app_js.index("function currentTeamChatRoomKey") : app_js.index("function scheduleTeamChatFastPolling") + ] + refresh_source = app_js[ + app_js.index("async function refreshTeamChatMessages") : app_js.index("async function markCurrentTeamChatRead") + ] + script = f""" +const firstMessage = {{ id: 1, room_key: "team:ops", content: "hello", created_at: "2026-07-12T00:00:00Z" }}; +const secondMessage = {{ id: 2, room_key: "team:ops", content: "new", created_at: "2026-07-12T00:00:01Z" }}; +const state = {{ + currentUser: {{ id: 7 }}, + teamChatRoom: {{ type: "team", recipientUserId: null }}, + teamChatGroup: {{ roomKey: "team:ops" }}, + teamChatMessages: [firstMessage], + teamChatLastMessageId: 1, +}}; +const refs = {{ + teamChatMessages: {{}}, + teamChatModal: {{ classList: {{ contains: () => true }} }}, +}}; +let renderCalls = 0; +let responseIndex = 0; +const responses = [[], [secondMessage]]; +async function fetchJSON() {{ + return {{ response: {{ ok: true }}, data: {{ messages: responses[responseIndex++] }} }}; +}} +function renderTeamChatMessages() {{ renderCalls += 1; }} +function setTeamChatStatus() {{}} +async function markCurrentTeamChatRead() {{}} +{room_params_source} +{merge_source} +{room_key_source} +{refresh_source} +const originalMessages = state.teamChatMessages; +const firstSuccess = await refreshTeamChatMessages({{ append: true }}); +const firstRenderCalls = renderCalls; +const preservedReference = state.teamChatMessages === originalMessages; +const secondSuccess = await refreshTeamChatMessages({{ append: true }}); +console.log(JSON.stringify({{ + firstSuccess, + secondSuccess, + firstRenderCalls, + totalRenderCalls: renderCalls, + preservedReference, + lastMessageId: state.teamChatLastMessageId, +}})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "firstSuccess": True, + "secondSuccess": True, + "firstRenderCalls": 0, + "totalRenderCalls": 1, + "preservedReference": True, + "lastMessageId": 2, + } + + +def test_team_chat_ignores_an_older_full_refresh_after_a_newer_incremental_response() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + room_params_source = app_js[ + app_js.index("function teamChatRoomParams") : app_js.index("function teamChatReadPayload") + ] + merge_source = app_js[ + app_js.index("function updateTeamChatLastMessageId") : app_js.index("function isOwnTeamChatMessage") + ] + room_key_source = app_js[ + app_js.index("function currentTeamChatRoomKey") : app_js.index("function scheduleTeamChatFastPolling") + ] + refresh_source = app_js[ + app_js.index("async function refreshTeamChatMessages") : app_js.index("async function markCurrentTeamChatRead") + ] + script = f""" +const first = {{ id: 1, room_key: "team:ops", content: "first", created_at: "2026-07-12T00:00:00Z" }}; +const second = {{ id: 2, room_key: "team:ops", content: "second", created_at: "2026-07-12T00:00:01Z" }}; +const state = {{ + currentUser: {{ id: 7 }}, + teamChatRoom: {{ type: "team", recipientUserId: null }}, + teamChatGroup: {{ roomKey: "team:ops" }}, + teamChatMessages: [first], + teamChatLastMessageId: 1, + teamChatMessageRequestSeq: 0, +}}; +const refs = {{ + teamChatMessages: {{}}, + teamChatModal: {{ classList: {{ contains: () => true }} }}, +}}; +let resolveFull; +let resolveAppend; +let renderCalls = 0; +async function fetchJSON(url) {{ + return await new Promise((resolve) => {{ + if (url.includes("after_id=")) resolveAppend = resolve; + else resolveFull = resolve; + }}); +}} +function renderTeamChatMessages() {{ renderCalls += 1; }} +function setTeamChatStatus() {{}} +async function markCurrentTeamChatRead() {{}} +{room_params_source} +{merge_source} +{room_key_source} +{refresh_source} +const olderFull = refreshTeamChatMessages(); +const newerAppend = refreshTeamChatMessages({{ append: true }}); +resolveAppend({{ response: {{ ok: true }}, data: {{ messages: [second] }} }}); +const appendResult = await newerAppend; +resolveFull({{ response: {{ ok: true }}, data: {{ messages: [first] }} }}); +const fullResult = await olderFull; +console.log(JSON.stringify({{ + appendResult, + fullResult, + ids: state.teamChatMessages.map((message) => message.id), + renderCalls, +}})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "appendResult": True, + "fullResult": False, + "ids": [1, 2], + "renderCalls": 1, + } + + +def test_team_chat_old_read_completion_cannot_clear_a_newer_request_error() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + refresh_source = app_js[ + app_js.index("async function refreshTeamChatMessages") : app_js.index("async function markCurrentTeamChatRead") + ] + script = f""" +const state = {{ + currentUser: {{ id: 7 }}, + teamChatMessageRequestSeq: 0, + teamChatLastMessageId: 1, +}}; +const refs = {{ + teamChatMessages: {{}}, + teamChatModal: {{ classList: {{ contains: () => false }} }}, +}}; +let requestCount = 0; +let releaseRead; +let notifyReadStarted; +const readStarted = new Promise((resolve) => {{ notifyReadStarted = resolve; }}); +const readGate = new Promise((resolve) => {{ releaseRead = resolve; }}); +let status = ""; +function currentTeamChatRoomKey() {{ return "team:ops"; }} +function teamChatRoomParams() {{ return new URLSearchParams(); }} +function mergeTeamChatMessages() {{ return false; }} +function renderTeamChatMessages() {{}} +function setTeamChatStatus(message) {{ status = message; }} +async function fetchJSON() {{ + requestCount += 1; + if (requestCount === 1) return {{ response: {{ ok: true }}, data: {{ messages: [] }} }}; + return {{ response: {{ ok: false }}, data: {{ error: "new request failed" }} }}; +}} +async function markCurrentTeamChatRead() {{ + notifyReadStarted(); + await readGate; +}} +{refresh_source} +const older = refreshTeamChatMessages().then((success) => {{ + if (success) setTeamChatStatus(""); + return success; +}}); +await readStarted; +const newerResult = await refreshTeamChatMessages(); +releaseRead(); +const olderResult = await older; +console.log(JSON.stringify({{ olderResult, newerResult, status }})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "olderResult": False, + "newerResult": False, + "status": "new request failed", + } + + +def test_asset_data_url_hydrates_missing_dimensions_for_logo_detection() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + dimension_source = app_js[ + app_js.index("async function ensureAssetDimensions") : app_js.index("function validateClientImageFile") + ] + script = f""" +async function loadImageElement() {{ return {{ naturalWidth: 1536, naturalHeight: 2048 }}; }} +function imageAssetDimensions() {{ return null; }} +{dimension_source} +const asset = {{ dataUrl: "data:image/png;base64,AAAA" }}; +await ensureAssetDataUrl(asset); +console.log(JSON.stringify(asset)); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "dataUrl": "data:image/png;base64,AAAA", + "width": 1536, + "height": 2048, + } + + +def test_team_chat_send_completion_is_bound_to_the_send_time_room() -> None: + app_js = (ROOT_DIR / "static" / "app.js").read_text(encoding="utf-8") + send_payload_source = app_js[ + app_js.index("function teamChatSendPayload") : app_js.index("function teamChatDisplayName") + ] + submit_source = app_js[ + app_js.index("async function submitTeamChatMessage") : app_js.index("async function submitChangePassword") + ] + script = f""" +const state = {{ + currentUser: {{ id: 7, username: "alice" }}, + teamChatGroup: {{ roomKey: "team:ops" }}, + teamChatRoom: {{ type: "team", recipientUserId: null }}, + teamChatMessages: [], + teamChatSending: false, + teamChatQuotedMessage: null, +}}; +const refs = {{ + teamChatMessageInput: {{ value: "hello", focus: () => {{}} }}, + sendTeamChatButton: {{ disabled: false, textContent: "发送" }}, +}}; +let resolveFetch; +let notifyFetchStarted; +const fetchStarted = new Promise((resolve) => {{ notifyFetchStarted = resolve; }}); +let sentPayload = null; +let replaceCalls = 0; +let renderCalls = 0; +let markReadCalls = 0; +async function fetchJSON(_url, options) {{ + sentPayload = JSON.parse(options.body); + notifyFetchStarted(); + return await new Promise((resolve) => {{ resolveFetch = resolve; }}); +}} +function currentTeamChatRoomKey() {{ + if (state.teamChatRoom.type === "team") return state.teamChatGroup.roomKey; + return `dm:7:${{state.teamChatRoom.recipientUserId}}`; +}} +function formatTeamChatOutgoingContent(content) {{ return content; }} +function clearTeamChatQuote() {{ state.teamChatQuotedMessage = null; }} +function createOptimisticTeamChatMessage(content) {{ + return {{ id: -1, client_id: "local-1", room_key: currentTeamChatRoomKey(), content, pending: true }}; +}} +function mergeTeamChatMessages(messages) {{ state.teamChatMessages = [...state.teamChatMessages, ...messages]; }} +function renderTeamChatMessages() {{ renderCalls += 1; }} +function setTeamChatSending(value) {{ state.teamChatSending = Boolean(value); }} +function setTeamChatStatus() {{}} +function restoreTeamChatDraft() {{}} +function replaceOptimisticTeamChatMessage(_clientId, messages) {{ + replaceCalls += 1; + state.teamChatMessages = messages; +}} +async function markCurrentTeamChatRead() {{ markReadCalls += 1; }} +function scheduleTeamChatFastPolling() {{}} +async function refreshTeamChatMessages() {{}} +{send_payload_source} +{submit_source} +const pending = submitTeamChatMessage({{ preventDefault: () => {{}} }}); +await fetchStarted; +state.teamChatRoom = {{ type: "dm", recipientUserId: 9 }}; +state.teamChatMessages = []; +resolveFetch({{ + response: {{ ok: true }}, + data: {{ + messages: [{{ id: 11, room_key: "team:ops", content: "hello" }}], + bot_reply_pending: false, + }}, +}}); +await pending; +console.log(JSON.stringify({{ + sentPayload, + replaceCalls, + renderCalls, + markReadCalls, + currentMessages: state.teamChatMessages, + sending: state.teamChatSending, +}})); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + result = json.loads(completed.stdout) + + assert result["sentPayload"] == { + "room_type": "team", + "recipient_user_id": None, + "content": "hello", + } + assert result["replaceCalls"] == 0 + assert result["renderCalls"] == 1 + assert result["markReadCalls"] == 0 + assert result["currentMessages"] == [] + assert result["sending"] is False def test_user_profile_ui_supports_avatar_and_editable_login_username() -> None: diff --git a/tests/test_storage.py b/tests/test_storage.py index 2de6ffb..2d9c73e 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -69,6 +69,79 @@ def encode(image: Image.Image) -> bytes: } +def test_masked_edit_composite_uses_png8_palette_transparency_as_alpha() -> None: + source = Image.new("RGBA", (4, 4), (220, 30, 30, 255)) + generated = Image.new("RGBA", (4, 4), (20, 80, 220, 255)) + mask = Image.new("P", (4, 4), 1) + mask.putpalette([0, 0, 0, 255, 255, 255] + [0, 0, 0] * 254) + mask.putpixel((1, 1), 0) + mask.putpixel((2, 2), 0) + mask.info["transparency"] = bytes([0, 255]) + + def encode(image: Image.Image) -> bytes: + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + mask_bytes = encode(mask) + assert b"tRNS" in mask_bytes + with Image.open(BytesIO(mask_bytes)) as persisted_mask: + assert persisted_mask.mode == "P" + assert persisted_mask.getbands() == ("P",) + assert "transparency" in persisted_mask.info + + result_bytes, result_mime, metadata = composite_masked_edit_image( + source_image_bytes=encode(source), + mask_image_bytes=mask_bytes, + generated_image_bytes=encode(generated), + generated_image_mime="image/png", + ) + + with Image.open(BytesIO(result_bytes)) as result: + rgba = result.convert("RGBA") + assert rgba.getpixel((0, 0)) == (220, 30, 30, 255) + assert rgba.getpixel((3, 3)) == (220, 30, 30, 255) + assert rgba.getpixel((1, 1)) == (20, 80, 220, 255) + assert rgba.getpixel((2, 2)) == (20, 80, 220, 255) + assert result_mime == "image/png" + assert metadata["mask_composited"] is True + + +@pytest.mark.parametrize("oversized_part", ["source", "mask", "generated"]) +def test_masked_edit_composite_rejects_oversized_inputs_before_decode(oversized_part: str) -> None: + images = { + "source": bytearray(_png_bytes(1, 1)), + "mask": bytearray(_png_bytes(1, 1)), + "generated": bytearray(_png_bytes(1, 1)), + } + images[oversized_part][16:20] = (4096).to_bytes(4, "big") + images[oversized_part][20:24] = (4096).to_bytes(4, "big") + + with pytest.raises(ValueError, match=rf"{oversized_part} image exceeds"): + composite_masked_edit_image( + source_image_bytes=bytes(images["source"]), + mask_image_bytes=bytes(images["mask"]), + generated_image_bytes=bytes(images["generated"]), + generated_image_mime="image/png", + ) + + +def test_masked_edit_composite_converts_pillow_decompression_bomb_to_value_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_output = BytesIO() + Image.new("RGB", (3, 1), (220, 30, 30)).save(source_output, format="TIFF") + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 1) + + with pytest.raises(ValueError, match=r"source image exceeds"): + composite_masked_edit_image( + source_image_bytes=source_output.getvalue(), + mask_image_bytes=_png_bytes(1, 1), + generated_image_bytes=_png_bytes(1, 1), + generated_image_mime="image/png", + ) + + 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)) diff --git a/tests/test_upstream_client.py b/tests/test_upstream_client.py index 4fa47da..2f3f84a 100644 --- a/tests/test_upstream_client.py +++ b/tests/test_upstream_client.py @@ -48,6 +48,20 @@ async def test_run_json_raises_after_max_retries() -> None: await client.aclose() +async def test_run_json_preserves_deep_upstream_400_without_recursion_error() -> None: + depth = 1100 + body = '{"error":' * depth + '{"message":"deep rejection"}' + "}" * depth + transport = httpx.MockTransport(lambda request: httpx.Response(400, text=body)) + client = await _build_client(transport, max_retries=0) + try: + with pytest.raises(APIError) as info: + await client.run_json("https://upstream.test/generate", "sk-test", {"prompt": "hi"}, "UA") + assert info.value.status == 400 + assert info.value.code == "upstream_error" + finally: + await client.aclose() + + async def test_run_json_reports_retry_exhaustion_to_user() -> None: transport = httpx.MockTransport( lambda req: httpx.Response( diff --git a/uv.lock b/uv.lock index 22eb31f..bd579df 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.65" +version = "0.1.66" source = { editable = "." } dependencies = [ { name = "anyio" },