feat(office): complete art rebuild with new asset index and UI polish
3
.gitignore
vendored
|
|
@ -25,3 +25,6 @@ cloudflared.out
|
|||
healthcheck.log
|
||||
backend.log
|
||||
frontend/office_bg.png
|
||||
runtime-config.json
|
||||
assets/bg-history/
|
||||
frontend/*.bak
|
||||
|
|
|
|||
23
LICENSE
|
|
@ -32,26 +32,21 @@ SOFTWARE.
|
|||
|
||||
### Important: Art Assets are NOT for commercial use
|
||||
|
||||
All art assets (including but not limited to: character sprites, scene backgrounds, posters, furniture, plants, coffee machine, server room, animations, and the full asset pack) are **non-commercial only**. They are for **learning, demonstration, and idea sharing only**.
|
||||
All art assets (including but not limited to character sprites, scene backgrounds,
|
||||
posters, furniture, plants, coffee machine, server room, animations, and full asset packs)
|
||||
are **non-commercial only**.
|
||||
|
||||
You may NOT use any art assets from this repository for any commercial purpose. If you want to use this project commercially, you **must create and replace all art assets with your own original work**.
|
||||
They are for **learning, demonstration, and idea sharing only**.
|
||||
|
||||
You may NOT use any art assets from this repository for commercial purposes.
|
||||
If you want to use this project commercially, you **must replace all art assets with your own original work**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Additional Disclaimer (Starmie / Pokémon)
|
||||
|
||||
- The main character "Starmie" is an existing IP from Nintendo/Pokémon and is **not original to this project**.
|
||||
- This project is a non-commercial fan creation only; the character was chosen for a fun homophone between "Starmie" and the author’s Chinese name "海辛" (Hǎi Xīn).
|
||||
- This project’s fan content is for learning, demonstration, and idea sharing only, with no commercial use.
|
||||
- Nintendo, Pokémon, and "Starmie" are trademarks or registered trademarks of Nintendo/The Pokémon Company.
|
||||
- If you plan to use any content related to this project, please assess compliance risks on your own and prioritize using your own original characters/art assets.
|
||||
|
||||
---
|
||||
|
||||
## 4. Guest Character Asset Attribution
|
||||
## 3. Guest Character Asset Attribution
|
||||
|
||||
Guest character animations use LimeZu’s free assets:
|
||||
- Animated Mini Characters 2 (Platformer) [FREE]
|
||||
- https://limezu.itch.io/animated-mini-characters-2-platform-free
|
||||
|
||||
Please keep this source attribution and follow the original author’s license terms when redistributing or demonstrating.
|
||||
Please keep this attribution and follow the original author’s license terms when redistributing or demonstrating.
|
||||
|
|
|
|||
76
README.md
|
|
@ -63,7 +63,20 @@ Star Office UI 目前实现了:
|
|||
4. **已适配手机端访问**
|
||||
- 移动端可直接打开与查看状态(适合外出时快速查看)。
|
||||
|
||||
5. **公网访问方式灵活**
|
||||
5. **支持中英日三语切换**
|
||||
- 已支持 CN / EN / JP 三语切换。
|
||||
- 语言切换会实时作用于界面文案、加载提示与角色气泡。
|
||||
|
||||
6. **支持自定义美术资产**
|
||||
- 支持在资产侧边栏替换角色/场景素材。
|
||||
- 支持动态素材重切帧与参数同步(frame size / frame range)以避免闪烁。
|
||||
|
||||
7. **支持接入自己的生图 API(可无限换背景)**
|
||||
- 支持接入自有生图 API 进行“搬新家/找中介”式背景更新。
|
||||
- 推荐模型:`nanobanana-pro` 或 `nanobanana-2`(结构保持更稳定)。
|
||||
- 基础看板功能不依赖 API;不接入 API 也可正常使用核心状态看板与资产管理。
|
||||
|
||||
8. **公网访问方式灵活**
|
||||
- Skill 默认建议使用 Cloudflare Tunnel 快速公网化。
|
||||
- 也可以使用你自己的公网域名 / 反向代理方案。
|
||||
|
||||
|
|
@ -141,17 +154,6 @@ python3 set_state.py idle "待命中"
|
|||
|
||||
请在二次发布/演示时保留来源说明,并遵守原作者许可条款。
|
||||
|
||||
### 其他资产说明与免责(重要)
|
||||
|
||||
- **主角色(宝石海星)与谐音说明**:
|
||||
- “宝石海星”是任天堂《宝可梦》(Pokémon)系列中已有的角色 IP,**不是本项目原创 IP**。
|
||||
- 本项目仅为**非商用二创/粉丝创作**:选择这个角色,是因为“宝石海星”与作者名字“海辛”在中文发音上有谐音趣味。
|
||||
- 本项目的二创内容仅供学习、演示、交流使用,**无任何商业用途**。
|
||||
- 任天堂、宝可梦、“宝石海星”均为任天堂/宝可梦公司的商标或注册商标。
|
||||
- 若你计划使用本项目相关内容,请使用你自己的原创角色/美术资产。
|
||||
|
||||
- **办公室场景与其他素材**:由本项目作者团队自行制作。
|
||||
|
||||
### 商用限制(重要)
|
||||
|
||||
- 代码玩法可以基于 MIT 使用与二次开发。
|
||||
|
|
@ -437,3 +439,53 @@ star-office-ui/
|
|||
README.md
|
||||
LICENSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9、2026-03 增量更新(在原版基础上补充)
|
||||
|
||||
> 本节仅记录“新增/变化”,其余内容保持原版结构不变。
|
||||
|
||||
### A) 房间装修生图模型推荐(新增)
|
||||
|
||||
在“搬新家 / 找中介”能力中,建议优先接入你自己的 Gemini 并使用:
|
||||
|
||||
1. **gemini nanobanana pro**
|
||||
2. **gemini nanobanana 2**
|
||||
|
||||
其他模型在“保持原房间结构 + 风格迁移一致性”上可能不如预期。
|
||||
|
||||
建议配置:
|
||||
|
||||
- `GEMINI_API_KEY`
|
||||
- `GEMINI_MODEL`(建议 `nanobanana-pro` 或 `nanobanana-2`)
|
||||
|
||||
此外,项目已支持运行时配置入口:
|
||||
- `GET /config/gemini`
|
||||
- `POST /config/gemini`
|
||||
|
||||
当 API key 缺失时,侧边栏会出现填写入口,用户可直接补填后重试。
|
||||
|
||||
### B) 资产编辑侧边栏验证码(新增)
|
||||
|
||||
侧边栏可以修改布局、装饰、默认位置,建议务必设置强验证码:
|
||||
|
||||
- 默认:`ASSET_DRAWER_PASS=1234`
|
||||
- 部署时建议覆盖:
|
||||
|
||||
```bash
|
||||
export ASSET_DRAWER_PASS="your-strong-pass"
|
||||
```
|
||||
|
||||
必要性:
|
||||
- 防止拿到访问链接的人修改你的房间布局与素材。
|
||||
|
||||
### C) 角色素材版权口径更新(重要)
|
||||
|
||||
当前主角状态(Writing / Idle / Syncing / Error)已切换为无版权争议的小猫素材。
|
||||
|
||||
因此:
|
||||
- README 中旧的任天堂/宝可梦角色说明不再作为当前版本口径。
|
||||
- 保留原则仍不变:
|
||||
- 代码逻辑 MIT
|
||||
- 美术资产禁止商用(学习/演示/交流用途)
|
||||
|
|
|
|||
75
SKILL.md
|
|
@ -125,3 +125,78 @@ cloudflared tunnel --url http://127.0.0.1:18791
|
|||
- 如果主人要对外访问,优先用 Cloudflare Tunnel(最快最方便)
|
||||
- 如果主人要改状态,你可以帮他调用 `set_state.py`,不用让他自己敲命令
|
||||
- 注意不要暴露你主人的内网地址或私有信息
|
||||
|
||||
---
|
||||
|
||||
## 8. 2026-03 增量补充(仅新增项)
|
||||
|
||||
### 8.1 生图模型建议(房间装修)
|
||||
|
||||
当用户使用“搬新家 / 找中介”时,优先推荐:
|
||||
|
||||
1. **gemini nanobanana pro**
|
||||
2. **gemini nanobanana 2**
|
||||
|
||||
其他模型在房间结构保持与风格一致性上可能不稳定。
|
||||
|
||||
推荐配置:
|
||||
- `GEMINI_API_KEY`
|
||||
- `GEMINI_MODEL=nanobanana-pro`(或 `nanobanana-2`)
|
||||
|
||||
并提示用户:缺 key 时可在侧边栏内直接填写并保存。
|
||||
|
||||
### 8.2 侧边栏验证码安全提醒(必须)
|
||||
|
||||
默认验证码为 `1234`,但生产/公网场景必须改强密码:
|
||||
|
||||
```bash
|
||||
export ASSET_DRAWER_PASS="your-strong-pass"
|
||||
```
|
||||
|
||||
理由:防止外部访问者修改房间布局、装饰和资产配置。
|
||||
|
||||
### 8.3 版权口径更新
|
||||
|
||||
主角状态素材已切换为无版权争议的小猫,不再沿用旧角色版权说明。
|
||||
|
||||
保留统一口径:
|
||||
- 代码:MIT
|
||||
- 美术资产:禁止商用
|
||||
|
||||
### 8.4 安装时必须提醒(API 可选)
|
||||
|
||||
在帮助主人安装时,需明确提醒:
|
||||
|
||||
- 现在支持接入自己的生图 API 来改美术资产与背景(可持续更换)。
|
||||
- 但基础功能(状态看板、多 Agent、资产替换/布局、三语切换)**不依赖 API**,不开 API 也能正常使用。
|
||||
|
||||
建议对主人口径:
|
||||
> 先把基础看板跑起来;需要“无限换背景/AI 生图装修”再接入自己的 API。
|
||||
|
||||
### 8.5 老用户更新指南(从旧版本升级)
|
||||
|
||||
如果主人之前已经下载过旧版,按以下步骤升级:
|
||||
|
||||
1. 进入项目目录并备份本地配置(如 `state.json`、自定义资产)。
|
||||
2. 拉取最新代码(`git pull` 或重新克隆到新目录)。
|
||||
3. 确认依赖:`python3 -m pip install -r backend/requirements.txt`。
|
||||
4. 保留并检查本地运行配置:
|
||||
- `ASSET_DRAWER_PASS`
|
||||
- `GEMINI_API_KEY` / `GEMINI_MODEL`(如需生图)
|
||||
5. 如有自定义位置,确认:
|
||||
- `asset-positions.json`
|
||||
- `asset-defaults.json`
|
||||
6. 重启后端并验收关键功能:
|
||||
- `/health`
|
||||
- 三语切换(CN/EN/JP)
|
||||
- 资产侧栏(选择、替换、设默认)
|
||||
- 生图入口(有 key 时可用)
|
||||
|
||||
### 8.6 功能更新提醒清单(对主人口播)
|
||||
|
||||
每次升级后,至少提醒主人以下变化:
|
||||
|
||||
1. 已支持 **CN/EN/JP 三语切换**(含 loading 与气泡实时联动)。
|
||||
2. 已支持 **自定义美术资产替换**(含动态素材切帧同步,减少闪烁)。
|
||||
3. 已支持 **接入自有生图 API** 持续更换背景(推荐 `nanobanana-pro` / `nanobanana-2`)。
|
||||
4. 新增/强化了安全项:`ASSET_DRAWER_PASS` 生产环境建议改强密码。
|
||||
|
|
|
|||
8
asset-defaults.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"flowers-bloom-v2.webp": {
|
||||
"x": 310.0,
|
||||
"y": 390.0,
|
||||
"scale": 0.8,
|
||||
"updated_at": "2026-03-03T01:32:18.211712"
|
||||
}
|
||||
}
|
||||
7
asset-positions.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"desk-v3.webp": {
|
||||
"x": 218.0,
|
||||
"y": 417.0,
|
||||
"updated_at": "2026-03-02T15:58:27.228023"
|
||||
}
|
||||
}
|
||||
BIN
assets/room-reference.png
Normal file
|
After Width: | Height: | Size: 742 KiB |
BIN
assets/room-reference.webp
Normal file
|
After Width: | Height: | Size: 103 KiB |
828
backend/app.py
|
|
@ -1,12 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Star Office UI - Backend State Service"""
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory, make_response, request
|
||||
from flask import Flask, jsonify, send_from_directory, make_response, request, session
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception:
|
||||
Image = None
|
||||
|
||||
# Paths (project-relative, no hardcoded absolute paths)
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
|
@ -15,6 +26,21 @@ FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend")
|
|||
STATE_FILE = os.path.join(ROOT_DIR, "state.json")
|
||||
AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json")
|
||||
JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json")
|
||||
FRONTEND_PATH = Path(FRONTEND_DIR)
|
||||
ASSET_ALLOWED_EXTS = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg", ".avif"}
|
||||
ASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, "assets-replace-template.zip")
|
||||
WORKSPACE_DIR = os.path.dirname(ROOT_DIR)
|
||||
GEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", "scripts", "gemini_image_generate.py")
|
||||
GEMINI_PYTHON = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", ".venv", "bin", "python")
|
||||
ROOM_REFERENCE_IMAGE = (
|
||||
os.path.join(ROOT_DIR, "assets", "room-reference.webp")
|
||||
if os.path.exists(os.path.join(ROOT_DIR, "assets", "room-reference.webp"))
|
||||
else os.path.join(ROOT_DIR, "assets", "room-reference.png")
|
||||
)
|
||||
BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history")
|
||||
ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json")
|
||||
ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json")
|
||||
RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json")
|
||||
|
||||
|
||||
def get_yesterday_date_str():
|
||||
|
|
@ -132,20 +158,41 @@ def extract_memo_from_file(file_path):
|
|||
return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」"
|
||||
|
||||
app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static")
|
||||
app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or "star-office-dev-secret-change-me"
|
||||
|
||||
# Guard join-agent critical section to enforce per-key concurrency under parallel requests
|
||||
join_lock = threading.Lock()
|
||||
|
||||
# Generate a version timestamp once at server startup for cache busting
|
||||
VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234")
|
||||
|
||||
|
||||
def _is_asset_editor_authed() -> bool:
|
||||
return bool(session.get("asset_editor_authed"))
|
||||
|
||||
|
||||
def _require_asset_editor_auth():
|
||||
if _is_asset_editor_authed():
|
||||
return None
|
||||
return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_no_cache_headers(response):
|
||||
"""Aggressively prevent caching for all responses"""
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
"""Apply cache policy by path:
|
||||
- HTML/API/state: no-cache (always fresh)
|
||||
- /static assets: long cache (filenames are versioned with ?v=VERSION_TIMESTAMP)
|
||||
"""
|
||||
path = (request.path or "")
|
||||
if path.startswith('/static/'):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
response.headers.pop("Pragma", None)
|
||||
response.headers.pop("Expires", None)
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
# Default state
|
||||
|
|
@ -264,20 +311,6 @@ DEFAULT_AGENTS = [
|
|||
"authStatus": "approved",
|
||||
"authExpiresAt": None,
|
||||
"lastPushAt": None
|
||||
},
|
||||
{
|
||||
"agentId": "npc1",
|
||||
"name": "NPC 1",
|
||||
"isMain": False,
|
||||
"state": "writing",
|
||||
"detail": "在整理热点日报...",
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"area": "writing",
|
||||
"source": "demo",
|
||||
"joinKey": None,
|
||||
"authStatus": "approved",
|
||||
"authExpiresAt": None,
|
||||
"lastPushAt": None
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -299,6 +332,63 @@ def save_agents_state(agents):
|
|||
json.dump(agents, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_asset_positions():
|
||||
if os.path.exists(ASSET_POSITIONS_FILE):
|
||||
try:
|
||||
with open(ASSET_POSITIONS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_asset_positions(data):
|
||||
with open(ASSET_POSITIONS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_asset_defaults():
|
||||
if os.path.exists(ASSET_DEFAULTS_FILE):
|
||||
try:
|
||||
with open(ASSET_DEFAULTS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_asset_defaults(data):
|
||||
with open(ASSET_DEFAULTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_runtime_config():
|
||||
base = {
|
||||
"gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "",
|
||||
"gemini_model": os.getenv("GEMINI_MODEL") or "gemini-3.1-flash-image-preview"
|
||||
}
|
||||
if os.path.exists(RUNTIME_CONFIG_FILE):
|
||||
try:
|
||||
with open(RUNTIME_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]})
|
||||
except Exception:
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def save_runtime_config(data):
|
||||
cfg = load_runtime_config()
|
||||
cfg.update(data or {})
|
||||
with open(RUNTIME_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_join_keys():
|
||||
if os.path.exists(JOIN_KEYS_FILE):
|
||||
try:
|
||||
|
|
@ -316,6 +406,141 @@ def save_join_keys(data):
|
|||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _ensure_magick_or_ffmpeg_available():
|
||||
if shutil.which("magick"):
|
||||
return "magick"
|
||||
if shutil.which("ffmpeg"):
|
||||
return "ffmpeg"
|
||||
return None
|
||||
|
||||
|
||||
def _probe_animated_frame_size(upload_path: str):
|
||||
"""Return (w,h) from first frame if possible."""
|
||||
if Image is not None:
|
||||
try:
|
||||
with Image.open(upload_path) as im:
|
||||
w, h = im.size
|
||||
return int(w), int(h)
|
||||
except Exception:
|
||||
pass
|
||||
# ffprobe fallback
|
||||
if shutil.which("ffprobe"):
|
||||
try:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0:s=x",
|
||||
upload_path,
|
||||
]
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip()
|
||||
if "x" in out:
|
||||
w, h = out.split("x", 1)
|
||||
return int(w), int(h)
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _animated_to_spritesheet(
|
||||
upload_path: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
out_ext: str = ".webp",
|
||||
preserve_original: bool = True,
|
||||
pixel_art: bool = True,
|
||||
cols: int | None = None,
|
||||
rows: int | None = None,
|
||||
):
|
||||
"""Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h)."""
|
||||
backend = _ensure_magick_or_ffmpeg_available()
|
||||
if not backend:
|
||||
raise RuntimeError("未检测到 ImageMagick/ffmpeg,无法自动转换动图")
|
||||
|
||||
ext = (out_ext or ".webp").lower()
|
||||
if ext not in {".webp", ".png"}:
|
||||
ext = ".webp"
|
||||
|
||||
out_fd, out_path = tempfile.mkstemp(suffix=ext)
|
||||
os.close(out_fd)
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
frames = 0
|
||||
out_fw, out_fh = int(frame_w), int(frame_h)
|
||||
if Image is not None:
|
||||
try:
|
||||
with Image.open(upload_path) as im:
|
||||
n = getattr(im, "n_frames", 1)
|
||||
# 默认保留用户原始帧尺寸(避免先压缩再放大导致像素糊)
|
||||
if preserve_original:
|
||||
out_fw, out_fh = im.size
|
||||
for i in range(n):
|
||||
im.seek(i)
|
||||
fr = im.convert("RGBA")
|
||||
if not preserve_original and (fr.size != (out_fw, out_fh)):
|
||||
resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS
|
||||
fr = fr.resize((out_fw, out_fh), resample)
|
||||
fr.save(os.path.join(td, f"f_{i:04d}.png"), "PNG")
|
||||
frames = n
|
||||
except Exception:
|
||||
frames = 0
|
||||
|
||||
if frames <= 0:
|
||||
cmd1 = f"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1"
|
||||
if os.system(cmd1) != 0:
|
||||
raise RuntimeError("动图抽帧失败(Pillow/ffmpeg 都失败)")
|
||||
files = sorted([x for x in os.listdir(td) if x.startswith("f_") and x.endswith(".png")])
|
||||
frames = len(files)
|
||||
if frames <= 0:
|
||||
raise RuntimeError("动图无有效帧")
|
||||
|
||||
if backend == "magick":
|
||||
# 像素风动图转精灵表默认无损,避免颜色/边缘被压缩糊掉
|
||||
quality_flag = "-define webp:lossless=true -define webp:method=6 -quality 100" if ext == ".webp" else ""
|
||||
# 允许按 cols/rows 排布;默认单行
|
||||
if cols is None or cols <= 0:
|
||||
cols_eff = frames
|
||||
else:
|
||||
cols_eff = max(1, int(cols))
|
||||
rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))
|
||||
|
||||
# 先规范单帧尺寸
|
||||
prep = ""
|
||||
if not preserve_original:
|
||||
magick_filter = "-filter point" if pixel_art else ""
|
||||
prep = f" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}"
|
||||
|
||||
cmd = (
|
||||
f"magick '{td}/f_*.png'{prep} "
|
||||
f"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'"
|
||||
)
|
||||
rc = os.system(cmd)
|
||||
if rc != 0:
|
||||
raise RuntimeError("ImageMagick 拼图失败")
|
||||
return out_path, cols_eff, rows_eff, frames, out_fw, out_fh
|
||||
|
||||
ffmpeg_quality = "-lossless 1 -compression_level 6 -q:v 100" if ext == ".webp" else ""
|
||||
cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames
|
||||
rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))
|
||||
if preserve_original:
|
||||
vf = f"tile={cols_eff}x{rows_eff}"
|
||||
else:
|
||||
scale_algo = "neighbor" if pixel_art else "lanczos"
|
||||
vf = (
|
||||
f"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo},"
|
||||
f"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,"
|
||||
f"tile={cols_eff}x{rows_eff}"
|
||||
)
|
||||
cmd2 = (
|
||||
f"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' "
|
||||
f"-vf '{vf}' "
|
||||
f"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1"
|
||||
)
|
||||
if os.system(cmd2) != 0:
|
||||
raise RuntimeError("ffmpeg 拼图失败")
|
||||
return out_path, frames, 1, frames, out_fw, out_fh
|
||||
|
||||
|
||||
def normalize_agent_state(s):
|
||||
"""归一化状态,提高兼容性。
|
||||
兼容输入:working/busy → writing; run/running → executing; sync → syncing; research → researching.
|
||||
|
|
@ -338,6 +563,155 @@ def normalize_agent_state(s):
|
|||
return 'idle'
|
||||
|
||||
|
||||
def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = "", speed_mode: str = "fast"):
|
||||
"""Generate RPG-style room background and save as webp.
|
||||
|
||||
speed_mode:
|
||||
- fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster)
|
||||
- quality: use configured model (fallback nanobanana-pro) + full 1280x720 path
|
||||
"""
|
||||
runtime_cfg = load_runtime_config()
|
||||
api_key = (runtime_cfg.get("gemini_api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError("MISSING_API_KEY")
|
||||
themes = [
|
||||
"8-bit dungeon guild room",
|
||||
"8-bit stardew-valley inspired cozy farm tavern",
|
||||
"8-bit nordic fantasy tavern",
|
||||
"8-bit magitech workshop",
|
||||
"8-bit elven forest inn",
|
||||
"8-bit pixel cyber tavern",
|
||||
"8-bit desert caravan inn",
|
||||
"8-bit snow mountain lodge",
|
||||
]
|
||||
theme = random.choice(themes)
|
||||
|
||||
if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)):
|
||||
raise RuntimeError("生图脚本环境缺失:gemini-image-generate 未安装")
|
||||
|
||||
style_hint = (custom_prompt or "").strip()
|
||||
if not style_hint:
|
||||
style_hint = theme
|
||||
|
||||
# 默认使用更稳妥的 quality 档,避免 fast 模型在部分 API 通道不可用
|
||||
mode = (speed_mode or "quality").strip().lower()
|
||||
if mode not in {"fast", "quality"}:
|
||||
mode = "fast"
|
||||
|
||||
configured_model = (runtime_cfg.get("gemini_model") or "").strip() or "gemini-3.1-flash-image-preview"
|
||||
if mode == "fast":
|
||||
selected_model = "nanobanana-2"
|
||||
# fast 也提高基础清晰度:从 1024x576 提升到 1152x648(牺牲少量速度)
|
||||
gen_width, gen_height = 1152, 648
|
||||
ref_width, ref_height = 1152, 648
|
||||
else:
|
||||
selected_model = configured_model
|
||||
gen_width, gen_height = width, height
|
||||
ref_width, ref_height = width, height
|
||||
|
||||
if mode == "fast" and selected_model not in {"nanobanana-2", "nanobanana-pro"}:
|
||||
selected_model = "nanobanana-2"
|
||||
|
||||
prompt = (
|
||||
"Use a top-down pixel room composition compatible with an office game scene. "
|
||||
"STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. "
|
||||
"Keep region layout stable (left work area, center lounge, right error area). "
|
||||
"Only change visual style/theme/material/lighting according to: " + style_hint + ". "
|
||||
"Do not add text or watermark. Retro 8-bit RPG style."
|
||||
)
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="rpg-bg-")
|
||||
cmd = [
|
||||
GEMINI_PYTHON,
|
||||
GEMINI_SCRIPT,
|
||||
"--prompt", prompt,
|
||||
"--aspect-ratio", "16:9",
|
||||
"--model", selected_model,
|
||||
"--out-dir", tmp_dir,
|
||||
"--cleanup",
|
||||
]
|
||||
|
||||
# 强约束:每次都带固定参考图,保持房间区域布局不漂移
|
||||
ref_for_call = None
|
||||
if os.path.exists(ROOM_REFERENCE_IMAGE):
|
||||
ref_for_call = ROOM_REFERENCE_IMAGE
|
||||
if mode == "fast" and Image is not None:
|
||||
try:
|
||||
ref_fast = os.path.join(tmp_dir, "room-reference-fast.webp")
|
||||
with Image.open(ROOM_REFERENCE_IMAGE) as rim:
|
||||
rim = rim.convert("RGBA").resize((ref_width, ref_height), Image.Resampling.LANCZOS)
|
||||
rim.save(ref_fast, "WEBP", quality=85, method=4)
|
||||
ref_for_call = ref_fast
|
||||
except Exception:
|
||||
ref_for_call = ROOM_REFERENCE_IMAGE
|
||||
|
||||
if ref_for_call:
|
||||
cmd.extend(["--reference-image", ref_for_call])
|
||||
|
||||
env = os.environ.copy()
|
||||
# 运行时配置优先:只保留 GEMINI_API_KEY,避免脚本因双 key 报错
|
||||
env.pop("GOOGLE_API_KEY", None)
|
||||
env["GEMINI_API_KEY"] = api_key
|
||||
env["GEMINI_MODEL"] = selected_model
|
||||
|
||||
def _run_cmd(cmd_args):
|
||||
return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240)
|
||||
|
||||
proc = _run_cmd(cmd)
|
||||
if proc.returncode != 0 and mode == "fast":
|
||||
err_text = (proc.stderr or proc.stdout or "").strip().lower()
|
||||
if ("not found" in err_text and "models/" in err_text) or ("model_not_available" in err_text):
|
||||
# fast 模型不可用时自动回退到稳定模型
|
||||
fallback_model = configured_model or "gemini-3.1-flash-image-preview"
|
||||
cmd_fallback = cmd[:]
|
||||
if "--model" in cmd_fallback:
|
||||
idx = cmd_fallback.index("--model")
|
||||
if idx + 1 < len(cmd_fallback):
|
||||
cmd_fallback[idx + 1] = fallback_model
|
||||
env["GEMINI_MODEL"] = fallback_model
|
||||
proc = _run_cmd(cmd_fallback)
|
||||
|
||||
if proc.returncode != 0:
|
||||
err_text = (proc.stderr or proc.stdout or "").strip()
|
||||
low = err_text.lower()
|
||||
if "your api key was reported as leaked" in low or "permission_denied" in low:
|
||||
raise RuntimeError("API_KEY_REVOKED_OR_LEAKED")
|
||||
if "not found" in low and "models/" in low:
|
||||
raise RuntimeError("MODEL_NOT_AVAILABLE")
|
||||
raise RuntimeError(f"生图失败: {err_text}")
|
||||
|
||||
try:
|
||||
result = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
except Exception:
|
||||
raise RuntimeError("生图结果解析失败")
|
||||
|
||||
files = result.get("files") or []
|
||||
if not files:
|
||||
raise RuntimeError("生图未返回文件")
|
||||
|
||||
gen_path = files[0]
|
||||
if not os.path.exists(gen_path):
|
||||
raise RuntimeError("生图文件不存在")
|
||||
|
||||
if Image is None:
|
||||
raise RuntimeError("Pillow 不可用,无法做尺寸标准化")
|
||||
|
||||
with Image.open(gen_path) as im:
|
||||
im = im.convert("RGBA")
|
||||
# 质量模式优先保细节;快速模式优先速度
|
||||
if mode == "fast":
|
||||
im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS)
|
||||
if (gen_width, gen_height) != (width, height):
|
||||
# fast 的放大改为 LANCZOS,牺牲少量速度换更高细节
|
||||
im = im.resize((width, height), Image.Resampling.LANCZOS)
|
||||
im.save(out_webp_path, "WEBP", quality=96, method=6)
|
||||
else:
|
||||
# quality:确保输出标准尺寸,同时使用无损 webp,减少压缩损失
|
||||
if im.size != (width, height):
|
||||
im = im.resize((width, height), Image.Resampling.LANCZOS)
|
||||
im.save(out_webp_path, "WEBP", lossless=True, quality=100, method=6)
|
||||
|
||||
|
||||
def state_to_area(state):
|
||||
area_map = {
|
||||
"idle": "breakroom",
|
||||
|
|
@ -806,6 +1180,422 @@ def set_state_endpoint():
|
|||
return jsonify({"status": "error", "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/template.zip", methods=["GET"])
|
||||
def assets_template_download():
|
||||
if not os.path.exists(ASSET_TEMPLATE_ZIP):
|
||||
return jsonify({"ok": False, "msg": "模板包不存在,请先生成"}), 404
|
||||
return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True)
|
||||
|
||||
|
||||
@app.route("/assets/list", methods=["GET"])
|
||||
def assets_list():
|
||||
items = []
|
||||
for p in FRONTEND_PATH.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = p.relative_to(FRONTEND_PATH).as_posix()
|
||||
if rel.startswith("fonts/"):
|
||||
continue
|
||||
if p.suffix.lower() not in ASSET_ALLOWED_EXTS:
|
||||
continue
|
||||
st = p.stat()
|
||||
width = None
|
||||
height = None
|
||||
if Image is not None:
|
||||
try:
|
||||
with Image.open(p) as im:
|
||||
width, height = im.size
|
||||
except Exception:
|
||||
pass
|
||||
items.append({
|
||||
"path": rel,
|
||||
"size": st.st_size,
|
||||
"ext": p.suffix.lower(),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
items.sort(key=lambda x: x["path"])
|
||||
return jsonify({"ok": True, "count": len(items), "items": items})
|
||||
|
||||
|
||||
@app.route("/assets/generate-rpg-background", methods=["POST"])
|
||||
def assets_generate_rpg_background():
|
||||
"""Generate a new RPG-themed background and replace office_bg_small.webp."""
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
req = request.get_json(silent=True) or {}
|
||||
custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else ""
|
||||
speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality"
|
||||
if speed_mode not in {"fast", "quality"}:
|
||||
speed_mode = "fast"
|
||||
|
||||
target = FRONTEND_PATH / "office_bg_small.webp"
|
||||
if not target.exists():
|
||||
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
|
||||
|
||||
# 覆盖前保留最近一次备份
|
||||
bak = target.with_suffix(target.suffix + ".bak")
|
||||
shutil.copy2(target, bak)
|
||||
|
||||
_generate_rpg_background_to_webp(
|
||||
str(target),
|
||||
width=1280,
|
||||
height=720,
|
||||
custom_prompt=custom_prompt,
|
||||
speed_mode=speed_mode,
|
||||
)
|
||||
|
||||
# 每次生成都归档一份历史底图(可回溯风格演化)
|
||||
os.makedirs(BG_HISTORY_DIR, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
hist_file = os.path.join(BG_HISTORY_DIR, f"office_bg_small-{ts}.webp")
|
||||
shutil.copy2(target, hist_file)
|
||||
|
||||
st = target.stat()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"path": "office_bg_small.webp",
|
||||
"size": st.st_size,
|
||||
"history": os.path.relpath(hist_file, ROOT_DIR),
|
||||
"speed_mode": speed_mode,
|
||||
"msg": "已生成并替换 RPG 房间底图(已自动归档)",
|
||||
})
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if msg == "MISSING_API_KEY":
|
||||
return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400
|
||||
if msg == "API_KEY_REVOKED_OR_LEAKED":
|
||||
return jsonify({"ok": False, "code": "API_KEY_REVOKED_OR_LEAKED", "msg": "API key is revoked or flagged as leaked. Please rotate to a new key."}), 400
|
||||
if msg == "MODEL_NOT_AVAILABLE":
|
||||
return jsonify({"ok": False, "code": "MODEL_NOT_AVAILABLE", "msg": "Configured model is not available for this API key/channel."}), 400
|
||||
return jsonify({"ok": False, "msg": msg}), 500
|
||||
|
||||
|
||||
@app.route("/assets/restore-reference-background", methods=["POST"])
|
||||
def assets_restore_reference_background():
|
||||
"""Restore office_bg_small.webp from fixed reference image."""
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
target = FRONTEND_PATH / "office_bg_small.webp"
|
||||
if not target.exists():
|
||||
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
|
||||
if not os.path.exists(ROOM_REFERENCE_IMAGE):
|
||||
return jsonify({"ok": False, "msg": "参考图不存在"}), 404
|
||||
|
||||
# 备份当前底图
|
||||
bak = target.with_suffix(target.suffix + ".bak")
|
||||
shutil.copy2(target, bak)
|
||||
|
||||
# 快速路径:若参考图已是 1280x720 的 webp,直接拷贝(秒级)
|
||||
ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower()
|
||||
fast_copied = False
|
||||
if ref_ext == '.webp':
|
||||
try:
|
||||
with Image.open(ROOM_REFERENCE_IMAGE) as rim:
|
||||
if rim.size == (1280, 720):
|
||||
shutil.copy2(ROOM_REFERENCE_IMAGE, target)
|
||||
fast_copied = True
|
||||
except Exception:
|
||||
fast_copied = False
|
||||
|
||||
# 慢路径:仅在必要时重编码
|
||||
if not fast_copied:
|
||||
if Image is None:
|
||||
return jsonify({"ok": False, "msg": "Pillow 不可用"}), 500
|
||||
with Image.open(ROOM_REFERENCE_IMAGE) as im:
|
||||
im = im.convert("RGBA").resize((1280, 720), Image.Resampling.LANCZOS)
|
||||
im.save(target, "WEBP", quality=92, method=6)
|
||||
|
||||
st = target.stat()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"path": "office_bg_small.webp",
|
||||
"size": st.st_size,
|
||||
"msg": "已恢复初始底图",
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/auth", methods=["POST"])
|
||||
def assets_auth():
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
pwd = (data.get("password") or "").strip()
|
||||
if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT:
|
||||
session["asset_editor_authed"] = True
|
||||
return jsonify({"ok": True, "msg": "认证成功"})
|
||||
return jsonify({"ok": False, "msg": "验证码错误"}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/auth/status", methods=["GET"])
|
||||
def assets_auth_status():
|
||||
return jsonify({"ok": True, "authed": _is_asset_editor_authed()})
|
||||
|
||||
|
||||
@app.route("/assets/positions", methods=["GET"])
|
||||
def assets_positions_get():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
return jsonify({"ok": True, "items": load_asset_positions()})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/positions", methods=["POST"])
|
||||
def assets_positions_set():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
key = (data.get("key") or "").strip()
|
||||
x = data.get("x")
|
||||
y = data.get("y")
|
||||
scale = data.get("scale")
|
||||
if not key:
|
||||
return jsonify({"ok": False, "msg": "缺少 key"}), 400
|
||||
if x is None or y is None:
|
||||
return jsonify({"ok": False, "msg": "缺少 x/y"}), 400
|
||||
x = float(x)
|
||||
y = float(y)
|
||||
if scale is None:
|
||||
scale = 1.0
|
||||
scale = float(scale)
|
||||
|
||||
all_pos = load_asset_positions()
|
||||
all_pos[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()}
|
||||
save_asset_positions(all_pos)
|
||||
return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/defaults", methods=["GET"])
|
||||
def assets_defaults_get():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
return jsonify({"ok": True, "items": load_asset_defaults()})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/defaults", methods=["POST"])
|
||||
def assets_defaults_set():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
key = (data.get("key") or "").strip()
|
||||
x = data.get("x")
|
||||
y = data.get("y")
|
||||
scale = data.get("scale")
|
||||
if not key:
|
||||
return jsonify({"ok": False, "msg": "缺少 key"}), 400
|
||||
if x is None or y is None:
|
||||
return jsonify({"ok": False, "msg": "缺少 x/y"}), 400
|
||||
x = float(x)
|
||||
y = float(y)
|
||||
if scale is None:
|
||||
scale = 1.0
|
||||
scale = float(scale)
|
||||
|
||||
all_defaults = load_asset_defaults()
|
||||
all_defaults[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()}
|
||||
save_asset_defaults(all_defaults)
|
||||
return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/config/gemini", methods=["GET"])
|
||||
def gemini_config_get():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
cfg = load_runtime_config()
|
||||
key = (cfg.get("gemini_api_key") or "").strip()
|
||||
masked = ("*" * max(0, len(key) - 4)) + key[-4:] if key else ""
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"has_api_key": bool(key),
|
||||
"api_key_masked": masked,
|
||||
"gemini_model": cfg.get("gemini_model") or "gemini-3.1-flash-image-preview",
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/config/gemini", methods=["POST"])
|
||||
def gemini_config_set():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
api_key = (data.get("api_key") or "").strip()
|
||||
model = (data.get("model") or "").strip() or "gemini-3.1-flash-image-preview"
|
||||
payload = {"gemini_model": model}
|
||||
if api_key:
|
||||
payload["gemini_api_key"] = api_key
|
||||
save_runtime_config(payload)
|
||||
return jsonify({"ok": True, "msg": "Gemini 配置已保存"})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/assets/upload", methods=["POST"])
|
||||
def assets_upload():
|
||||
guard = _require_asset_editor_auth()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
rel_path = (request.form.get("path") or "").strip().lstrip("/")
|
||||
backup = (request.form.get("backup") or "1").strip() != "0"
|
||||
f = request.files.get("file")
|
||||
|
||||
if not rel_path or f is None:
|
||||
return jsonify({"ok": False, "msg": "缺少 path 或 file"}), 400
|
||||
|
||||
target = (FRONTEND_PATH / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(FRONTEND_PATH.resolve())
|
||||
except Exception:
|
||||
return jsonify({"ok": False, "msg": "非法 path"}), 400
|
||||
|
||||
if target.suffix.lower() not in ASSET_ALLOWED_EXTS:
|
||||
return jsonify({"ok": False, "msg": "仅允许上传图片/美术资源类型"}), 400
|
||||
|
||||
if not target.exists():
|
||||
return jsonify({"ok": False, "msg": "目标文件不存在,请先从 /assets/list 选择 path"}), 404
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if backup:
|
||||
bak = target.with_suffix(target.suffix + ".bak")
|
||||
shutil.copy2(target, bak)
|
||||
|
||||
auto_sheet = (request.form.get("auto_spritesheet") or "0").strip() == "1"
|
||||
ext_name = (f.filename or "").lower()
|
||||
|
||||
if auto_sheet and target.suffix.lower() in {".webp", ".png"}:
|
||||
with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or ".gif", delete=False) as tf:
|
||||
src_path = tf.name
|
||||
f.save(src_path)
|
||||
try:
|
||||
in_w, in_h = _probe_animated_frame_size(src_path)
|
||||
frame_w = int(request.form.get("frame_w") or (in_w or 64))
|
||||
frame_h = int(request.form.get("frame_h") or (in_h or 64))
|
||||
|
||||
# 如果是静态图上传到精灵表目标,按网格切片而不是整图覆盖
|
||||
if not (ext_name.endswith(".gif") or ext_name.endswith(".webp")) and Image is not None:
|
||||
try:
|
||||
with Image.open(src_path) as sim:
|
||||
sim = sim.convert("RGBA")
|
||||
sw, sh = sim.size
|
||||
if frame_w <= 0 or frame_h <= 0:
|
||||
frame_w, frame_h = sw, sh
|
||||
cols = max(1, sw // frame_w)
|
||||
rows = max(1, sh // frame_h)
|
||||
sheet_w = cols * frame_w
|
||||
sheet_h = rows * frame_h
|
||||
if sheet_w <= 0 or sheet_h <= 0:
|
||||
raise RuntimeError("静态图尺寸与帧规格不匹配")
|
||||
|
||||
cropped = sim.crop((0, 0, sheet_w, sheet_h))
|
||||
# 目标是 webp 仍按无损保存,避免像素损失
|
||||
if target.suffix.lower() == ".webp":
|
||||
cropped.save(str(target), "WEBP", lossless=True, quality=100, method=6)
|
||||
else:
|
||||
cropped.save(str(target), "PNG")
|
||||
|
||||
st = target.stat()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"path": rel_path,
|
||||
"size": st.st_size,
|
||||
"backup": backup,
|
||||
"converted": {
|
||||
"from": ext_name.split(".")[-1] if "." in ext_name else "image",
|
||||
"to": "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet",
|
||||
"frame_w": frame_w,
|
||||
"frame_h": frame_h,
|
||||
"columns": cols,
|
||||
"rows": rows,
|
||||
"frames": cols * rows,
|
||||
"preserve_original": False,
|
||||
"pixel_art": True,
|
||||
}
|
||||
})
|
||||
finally:
|
||||
pass
|
||||
|
||||
# 默认:优先保留输入帧尺寸;若前端传了强制值则按前端。
|
||||
preserve_original_val = request.form.get("preserve_original")
|
||||
if preserve_original_val is None:
|
||||
preserve_original = True
|
||||
else:
|
||||
preserve_original = preserve_original_val.strip() == "1"
|
||||
|
||||
pixel_art = (request.form.get("pixel_art") or "1").strip() == "1"
|
||||
req_cols = int(request.form.get("cols") or 0)
|
||||
req_rows = int(request.form.get("rows") or 0)
|
||||
sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet(
|
||||
src_path,
|
||||
frame_w,
|
||||
frame_h,
|
||||
out_ext=target.suffix.lower(),
|
||||
preserve_original=preserve_original,
|
||||
pixel_art=pixel_art,
|
||||
cols=(req_cols if req_cols > 0 else None),
|
||||
rows=(req_rows if req_rows > 0 else None),
|
||||
)
|
||||
shutil.move(sheet_path, str(target))
|
||||
st = target.stat()
|
||||
from_type = "gif" if ext_name.endswith(".gif") else "webp"
|
||||
to_type = "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet"
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"path": rel_path,
|
||||
"size": st.st_size,
|
||||
"backup": backup,
|
||||
"converted": {
|
||||
"from": from_type,
|
||||
"to": to_type,
|
||||
"frame_w": out_fw,
|
||||
"frame_h": out_fh,
|
||||
"columns": cols,
|
||||
"rows": rows,
|
||||
"frames": frames,
|
||||
"preserve_original": preserve_original,
|
||||
"pixel_art": pixel_art,
|
||||
}
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
os.remove(src_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
f.save(str(target))
|
||||
st = target.stat()
|
||||
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "backup": backup})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print("Star Office UI - Backend State Service")
|
||||
|
|
|
|||
37
dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Star-Office-UI Release Notes (2026-03-02)
|
||||
|
||||
## Summary
|
||||
This package is a cleaned release snapshot for handoff/update.
|
||||
It excludes runtime files, logs, and local backup artifacts.
|
||||
|
||||
## Included
|
||||
- backend/
|
||||
- frontend/
|
||||
- docs/
|
||||
- assets/room-reference.png
|
||||
- core scripts and docs (README.md, SKILL.md, LICENSE, set_state.py, etc.)
|
||||
- asset-defaults.json / asset-positions.json
|
||||
|
||||
## Excluded on purpose
|
||||
- .git/
|
||||
- .venv/
|
||||
- __pycache__/
|
||||
- *.log / *.out / *.pid
|
||||
- *.bak (frontend image backups)
|
||||
- assets/bg-history/ (historical generated backgrounds)
|
||||
- runtime state files: state.json / agents-state.json / join-keys.json
|
||||
|
||||
## Artifact
|
||||
- File: `dist/Star-Office-UI-release-20260302.tgz`
|
||||
- SHA256: `bf52147b7664adc3c457eadd3748f969b1ad5ee7e8d3059ce9c8da4c6030f6ae`
|
||||
|
||||
## Pre-publish checklist
|
||||
1. Confirm whether `asset-defaults.json` and `asset-positions.json` should be shipped as current defaults.
|
||||
2. Confirm whether `assets/bg-history/` should remain local-only (currently excluded).
|
||||
3. On target machine, create fresh `state.json` and `join-keys.json` if needed.
|
||||
4. Start backend and validate:
|
||||
- `/health`
|
||||
- `/status`
|
||||
- language switches (EN/JP/CN)
|
||||
- loading overlay + sidebar layering
|
||||
- asset drawer selection / upload panel behavior
|
||||
33
docs/CHANGELOG_2026-03.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Changelog — 2026-03 Refresh
|
||||
|
||||
## Highlights
|
||||
|
||||
- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split)
|
||||
- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching
|
||||
- Added room loading overlay with emoji rotation and localized copy
|
||||
- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit)
|
||||
- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync
|
||||
- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1
|
||||
- Disabled error movement path (error anim stays in place)
|
||||
- Removed GIF legacy assets and stale references
|
||||
- Restored `assets/room-reference.png` for reference background restore
|
||||
- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`)
|
||||
- Improved startup performance:
|
||||
- static assets use long cache headers
|
||||
- local phaser vendor restored
|
||||
|
||||
## Security / Config
|
||||
|
||||
- Asset drawer default pass changed to `1234`
|
||||
- Recommend deployment override:
|
||||
- `ASSET_DRAWER_PASS=<your-strong-pass>`
|
||||
- Rationale: prevent unauthorized layout/asset modifications from shared links
|
||||
|
||||
## AI model recommendation for room generation
|
||||
|
||||
For best style-transfer quality (while preserving room structure), recommend:
|
||||
|
||||
1. gemini nanobanana pro
|
||||
2. gemini nanobanana 2
|
||||
|
||||
Other models may produce unstable structure consistency.
|
||||
87
docs/PR_DRAFT_2026-03-refresh.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# PR Draft — Star Office UI March Refresh
|
||||
|
||||
## Title
|
||||
feat: asset editor + i18n + loading UX + sprite pipeline + security/perf refinements
|
||||
|
||||
## Summary
|
||||
This PR delivers a full refresh of Star Office UI across UX, asset pipeline, localization, stability, and deployment security.
|
||||
|
||||
### What changed
|
||||
|
||||
#### 1) Asset editor / room decoration
|
||||
- Improved drawer selection UX (select/deselect + dual highlight sync)
|
||||
- Upload panel now appears only when an asset is selected
|
||||
- Added defaults/overrides split:
|
||||
- `GET/POST /assets/defaults`
|
||||
- `GET/POST /assets/positions`
|
||||
- Added “Set Default” flow for persistent base placement
|
||||
|
||||
#### 2) Localization (CN/EN/JP)
|
||||
- Replaced language toggles with EN / JP / CN buttons
|
||||
- Active language button highlighted in green
|
||||
- Real-time language switching for:
|
||||
- UI labels
|
||||
- loading texts
|
||||
- role/cat/guest bubbles
|
||||
- initial boot loading sentence
|
||||
|
||||
#### 3) Loading overlay and UX polish
|
||||
- Added room-bound loading overlay with emoji rotation
|
||||
- Updated copy to voyage-themed localized sets
|
||||
- Trigger timing fixed: overlay shows immediately on click
|
||||
- Overlay and detail placement bound to canvas rect for consistency
|
||||
|
||||
#### 4) Layout and layering fixes
|
||||
- Fixed canvas border fit and theme color unification (#64477d)
|
||||
- Ensured status/detail stays inside canvas and single-line clipped
|
||||
- Drawer open now shifts main stage to avoid overlap and large gaps
|
||||
- Drawer kept above room loading overlay
|
||||
|
||||
#### 5) Sprite replacement pipeline hardening
|
||||
- Reworked replacement flow to detect frame size/count from incoming animated webp
|
||||
- Synced loader + animation frame ranges to avoid flicker
|
||||
- Applied across Writing / Idle / Syncing / Error replacements
|
||||
- Syncing behavior adjusted:
|
||||
- non-sync state shows frame 0
|
||||
- syncing animation starts from frame 1
|
||||
- Error animation movement path removed (fixed in place)
|
||||
|
||||
#### 6) Cleanup / reliability / perf
|
||||
- Removed legacy GIF assets
|
||||
- Removed stale asset references (zero missing static refs)
|
||||
- Restored `assets/room-reference.png` for restore-reference endpoint
|
||||
- Added configurable drawer pass via env:
|
||||
- `ASSET_DRAWER_PASS` (default `1234`)
|
||||
- Performance improvements:
|
||||
- static assets served with long cache headers
|
||||
- local phaser vendor restored to reduce cold-load latency
|
||||
|
||||
## Documentation updates included
|
||||
- README rewritten for latest behavior/config
|
||||
- SKILL updated with deployment + safety + replacement SOP
|
||||
- LICENSE updated to remove old third-party character disclaimer and keep:
|
||||
- code MIT
|
||||
- art assets non-commercial
|
||||
- Added `docs/CHANGELOG_2026-03.md`
|
||||
|
||||
## Deployment notes
|
||||
- Recommended model for room generation:
|
||||
1. gemini nanobanana pro
|
||||
2. gemini nanobanana 2
|
||||
- Security recommendation:
|
||||
- always override `ASSET_DRAWER_PASS` in production/public deployments
|
||||
|
||||
## Test checklist
|
||||
- [ ] Open page cold + warm load
|
||||
- [ ] Switch CN/EN/JP at any state
|
||||
- [ ] Trigger Move Home/Broker and observe local status text + loading overlay
|
||||
- [ ] Replace one animated asset and verify frame sync/no flicker
|
||||
- [ ] Verify Error is fixed in place
|
||||
- [ ] Verify `/assets/restore-reference-background` works with `assets/room-reference.png`
|
||||
- [ ] Verify no missing `/static/*` refs in runtime logs
|
||||
|
||||
## How to create PR
|
||||
1. `git checkout -b feat/march-refresh`
|
||||
2. `git push -u origin feat/march-refresh`
|
||||
3. Open PR to `ringhyacinth/Star-Office-UI:main`
|
||||
4. Paste this document as PR description
|
||||
25
docs/PR_FILELIST_2026-03-refresh.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# PR File List — 2026-03 Refresh
|
||||
|
||||
## Core code changes
|
||||
- `frontend/index.html`
|
||||
- `backend/app.py`
|
||||
- `frontend/vendor/phaser-3.80.1.min.js`
|
||||
- `assets/room-reference.webp`
|
||||
|
||||
## Configuration / templates
|
||||
- `.gitignore`
|
||||
- `runtime-config.sample.json`
|
||||
|
||||
## Documentation
|
||||
- `README.md`
|
||||
- `SKILL.md`
|
||||
- `LICENSE`
|
||||
- `docs/CHANGELOG_2026-03.md`
|
||||
- `docs/PR_DRAFT_2026-03-refresh.md`
|
||||
- `docs/PR_FILELIST_2026-03-refresh.md`
|
||||
|
||||
## Notes (excluded from PR)
|
||||
- `state.json`, `agents-state.json`, `runtime-config.json` (local runtime)
|
||||
- `assets/bg-history/` (local generated history)
|
||||
- `frontend/*.bak` (local backups)
|
||||
- temporary dist packages
|
||||
BIN
frontend/btn-back-home-sprite.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
frontend/btn-broker-sprite.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
frontend/btn-diy-sprite.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
frontend/btn-move-house-sprite.png
Normal file
|
After Width: | Height: | Size: 503 B |
BIN
frontend/btn-open-drawer-sprite.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/btn-state-sprite.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 836 KiB |
BIN
frontend/coffee-machine-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
frontend/coffee-machine-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
BIN
frontend/desk-v3.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
frontend/flowers-bloom-v2.webp
Normal file
|
After Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
|
@ -462,6 +462,7 @@ function create() {
|
|||
'flowers',
|
||||
randomFlowerFrame
|
||||
).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y);
|
||||
flower.setScale(LAYOUT.furniture.flower.scale || 1);
|
||||
flower.setDepth(LAYOUT.furniture.flower.depth);
|
||||
flower.setInteractive({ useHandCursor: true });
|
||||
window.flowerSprite = flower;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 914 B |
|
Before Width: | Height: | Size: 1,007 B |
|
Before Width: | Height: | Size: 1,007 B |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
2944
frontend/index.html
|
|
@ -43,9 +43,10 @@ const LAYOUT = {
|
|||
// 桌上花盆
|
||||
flower: {
|
||||
x: 310,
|
||||
y: 405,
|
||||
y: 390,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 1100
|
||||
depth: 1100,
|
||||
scale: 0.8
|
||||
},
|
||||
|
||||
// Star 在桌前工作(在 desk 下面)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 821 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
frontend/sofa-idle-v3.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
BIN
frontend/sofa-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 318 KiB |
BIN
frontend/star-idle-v4.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 952 KiB |
|
Before Width: | Height: | Size: 677 KiB |
|
Before Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 4 MiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
BIN
frontend/sync-animation-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 563 KiB |
1
frontend/vendor/phaser-3.80.1.min.js
vendored
Normal file
|
|
@ -1,49 +1,3 @@
|
|||
{
|
||||
"keys": [
|
||||
{
|
||||
"key": "ocj_starteam01",
|
||||
"name": "团队成员 01",
|
||||
"maxConcurrent": 3,
|
||||
"used": true,
|
||||
"usedBy": "阿龙虾",
|
||||
"usedByAgentId": "agent_1772347959385_px4w",
|
||||
"usedAt": "2026-03-01T14:52:39.385922",
|
||||
"reusable": true
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam02",
|
||||
"name": "团队成员 02",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam03",
|
||||
"name": "团队成员 03",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam04",
|
||||
"name": "团队成员 04",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam05",
|
||||
"name": "团队成员 05",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam06",
|
||||
"name": "团队成员 06",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam07",
|
||||
"name": "团队成员 07",
|
||||
"maxConcurrent": 3
|
||||
},
|
||||
{
|
||||
"key": "ocj_starteam08",
|
||||
"name": "团队成员 08",
|
||||
"maxConcurrent": 3
|
||||
}
|
||||
]
|
||||
"keys": []
|
||||
}
|
||||
4
runtime-config.sample.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"gemini_api_key": "YOUR_GEMINI_API_KEY",
|
||||
"gemini_model": "nanobanana-pro"
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import os
|
|||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
STATE_FILE = "/root/.openclaw/workspace/star-office-ui/state.json"
|
||||
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
|
||||
|
||||
VALID_STATES = [
|
||||
"idle",
|
||||
|
|
|
|||