Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 控制台。

Expand All @@ -14,7 +14,14 @@

![PicGen Console 主程序界面](demo1.png)

## 0.1.65 主要特性
## 0.1.66 主要特性

- **简洁模式壳层统一**:顶栏、状态、预览和页脚使用既有 Arco token 重做;专业模式除模式按钮和“我的收藏”需求文案外保持原像素。
- **编辑 Logo 缩放检测闭环**:检测坐标按源画布到结果画布的宽度比例缩放,缺失尺寸会先补齐;保留判定记录匹配率、像素数、阈值和候选关联,便于追查误判。
- **蒙版与错误边界加固**:PNG-8 `tRNS` 蒙版恢复逐像素保留;空 `param` 可继续触发候选数降级;深层错误体和 Pillow 解压炸弹异常均受控处理。
- **收藏与聊天状态诚实**:“团队灵感流”改为“我的收藏”,可见共享图片支持各自收藏;取消筛选会同步隐藏说明,聊天轮询不重绘未变化消息且隔离跨房间和乱序状态。
- **行程图稳定性提升**:字体子集失败会继续尝试候选目录,SVG 渲染移出事件循环,长标题和副标题分档缩小并安全截断。
- **多图结果数量透明**:Responses 请求多张但上游少返回时,结果区明确显示请求数和返回数。

- **模式切换更醒目**:简洁模式和专业模式的切换按钮使用主绿色与白色图文,在桌面和移动端都更容易识别。

Expand Down Expand Up @@ -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
```

或:
Expand All @@ -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
Expand Down Expand Up @@ -263,7 +270,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.65 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.66 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.65
image: minorli/picgen:0.1.66
build:
context: .
ports:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
44 changes: 32 additions & 12 deletions src/picgen/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
"""
Expand Down
111 changes: 35 additions & 76 deletions src/picgen/itinerary_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -109,15 +115,15 @@ 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 */"
f"@font-face{{font-family:'{TITLE_FONT_FAMILY}';"
"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:
Expand All @@ -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")),
Expand Down Expand Up @@ -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])
Expand All @@ -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'<line x1="{x - cx:.1f}" y1="{y - cy:.1f}" x2="0" y2="0" class="map-index-leader"/>'
nodes.append(
f'<g class="map-index-badge" transform="translate({cx:.1f} {cy:.1f})">'
f"{leader}"
'<circle cx="0" cy="0" r="15" fill="#fff7c7" opacity="0.96"/>'
'<circle cx="0" cy="0" r="13" fill="none" stroke="#7f5f45" stroke-width="1.35"/>'
f'<text x="0" y="6" text-anchor="middle" class="map-index">{index + 1}</text>'
"</g>"
)
return nodes


def _label_candidate_centers(
index: int,
point: dict[str, Any],
Expand Down Expand Up @@ -1767,8 +1730,8 @@ def render_itinerary_map_svg(
f'<path d="{segment["path"]}" class="route-line{" transfer" if segment["transfer"] else ""}"/>'
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 = (
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -1907,26 +1865,29 @@ 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;"
"opacity:.54;letter-spacing:0;paint-order:stroke;stroke:#fff7df;stroke-width:7px;stroke-linejoin:round;"
"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}",
Expand Down Expand Up @@ -1982,9 +1943,7 @@ def render_itinerary_map_svg(
'<g data-layer="program-country-labels">',
*country_nodes,
"</g>",
'<g data-layer="program-index-labels">',
*index_nodes,
"</g>",
'<g data-layer="program-index-labels"></g>',
'<g data-layer="program-labels">',
*label_nodes,
"</g>",
Expand Down
Loading
Loading