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 @@

-## 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 @@ 结果对比
点击放大
+
+
+
+
@@ -1061,7 +1065,7 @@
管理员高级设置
@@ -1371,6 +1375,6 @@ 图片预览
-
+