diff --git a/RULES.md b/RULES.md
index cb6586c..3aa200a 100644
--- a/RULES.md
+++ b/RULES.md
@@ -12,7 +12,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
-- 当前产品方向(2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;AI 润色只扩写用户明确写出的主体、品牌、产品、平台、动作和镜头,用户没写 `SKG` 时绝不主动加入 SKG,也不能把未知主体润成人物或强行润成无人物;上传/生成的参考图如果本来就有人物,应在视频提示词里按 AI 生成的虚拟角色、非真人、非公众人物处理,继续允许 AI 人像素材参与图生视频;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果、详情页、画布项目和个人工作流模板,继续沿用后端 owner 隔离;画布项目和我的工作流以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为项目缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
+- 当前产品方向(2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;AI 润色只扩写用户明确写出的主体、品牌、产品、平台、动作和镜头,用户没写 `SKG` 时绝不主动加入 SKG,也不能把未知主体润成人物或强行润成无人物;上传/生成的参考图如果本来就有人物,应在视频提示词里按 AI 生成的虚拟角色、非真人、非公众人物处理,继续允许 AI 人像素材参与图生视频;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉;生图配置必须显示真实像素尺寸和低/中/高画质,比例不能替代尺寸,生成结果也要显示实际输出像素。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果、详情页、画布项目和个人工作流模板,继续沿用后端 owner 隔离;画布项目和我的工作流以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为项目缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
## 部署事实
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
diff --git a/api/main.py b/api/main.py
index 1c8261e..2c2e060 100644
--- a/api/main.py
+++ b/api/main.py
@@ -132,26 +132,139 @@ IMAGE_SIZE_CHOICES = [
"id": "auto",
"label": "自动",
"value": "auto",
- "description": "由图片模型自行决定输出尺寸",
+ "ratio": "auto",
+ "width": 0,
+ "height": 0,
+ "description": "由图片模型自行决定输出尺寸,生成后显示实际像素",
},
{
"id": "1024x1536",
- "label": "竖图 2:3",
+ "label": "竖图 2:3 · 1024×1536",
"value": "1024x1536",
+ "ratio": "2:3",
+ "width": 1024,
+ "height": 1536,
"description": "适合信息流营销图、人物和产品竖版构图",
},
+ {
+ "id": "1536x2304",
+ "label": "竖图 2:3 · 1536×2304",
+ "value": "1536x2304",
+ "ratio": "2:3",
+ "width": 1536,
+ "height": 2304,
+ "description": "适合高精细竖版海报和后期裁切",
+ },
+ {
+ "id": "1088x1920",
+ "label": "竖屏 9:16 · 1088×1920",
+ "value": "1088x1920",
+ "ratio": "9:16",
+ "width": 1088,
+ "height": 1920,
+ "description": "接近 1080p 竖屏,宽度按 16 像素倍数提交",
+ },
+ {
+ "id": "1440x2560",
+ "label": "竖屏 9:16 · 1440×2560",
+ "value": "1440x2560",
+ "ratio": "9:16",
+ "width": 1440,
+ "height": 2560,
+ "description": "适合短视频封面、竖屏高清素材和二次裁切",
+ },
+ {
+ "id": "960x1280",
+ "label": "竖图 3:4 · 960×1280",
+ "value": "960x1280",
+ "ratio": "3:4",
+ "width": 960,
+ "height": 1280,
+ "description": "适合偏人物或产品竖图,文件体积较轻",
+ },
+ {
+ "id": "1536x1920",
+ "label": "竖图 4:5 · 1536×1920",
+ "value": "1536x1920",
+ "ratio": "4:5",
+ "width": 1536,
+ "height": 1920,
+ "description": "适合小红书、社媒封面和产品展示图",
+ },
{
"id": "1024x1024",
- "label": "方图 1:1",
+ "label": "方图 1:1 · 1024×1024",
"value": "1024x1024",
+ "ratio": "1:1",
+ "width": 1024,
+ "height": 1024,
"description": "适合头像、方形素材和电商图",
},
+ {
+ "id": "2048x2048",
+ "label": "方图 1:1 · 2048×2048",
+ "value": "2048x2048",
+ "ratio": "1:1",
+ "width": 2048,
+ "height": 2048,
+ "description": "适合高清方形素材和后期抠图",
+ },
{
"id": "1536x1024",
- "label": "横图 3:2",
+ "label": "横图 3:2 · 1536×1024",
"value": "1536x1024",
+ "ratio": "3:2",
+ "width": 1536,
+ "height": 1024,
"description": "适合横版封面和详情页配图",
},
+ {
+ "id": "2304x1536",
+ "label": "横图 3:2 · 2304×1536",
+ "value": "2304x1536",
+ "ratio": "3:2",
+ "width": 2304,
+ "height": 1536,
+ "description": "适合高清横版主视觉和详情页大图",
+ },
+ {
+ "id": "1280x720",
+ "label": "横屏 16:9 · 1280×720",
+ "value": "1280x720",
+ "ratio": "16:9",
+ "width": 1280,
+ "height": 720,
+ "description": "适合轻量横版封面、网页首屏和视频首帧",
+ },
+ {
+ "id": "2048x1152",
+ "label": "横屏 16:9 · 2048×1152",
+ "value": "2048x1152",
+ "ratio": "16:9",
+ "width": 2048,
+ "height": 1152,
+ "description": "适合高清横版视频封面和大屏展示",
+ },
+]
+IMAGE_QUALITY_CHOICES = [
+ {
+ "id": "low",
+ "label": "低 · 快速草稿",
+ "value": "low",
+ "description": "更快生成,适合批量试方向",
+ },
+ {
+ "id": "medium",
+ "label": "中 · 常规出图",
+ "value": "medium",
+ "description": "速度和质量折中,适合日常迭代",
+ },
+ {
+ "id": "high",
+ "label": "高 · 最终稿",
+ "value": "high",
+ "description": "质量优先,适合定稿和高清素材",
+ },
]
VIDEO_SIZE_CHOICES = [
{
@@ -454,6 +567,10 @@ class GeneratedImage(BaseModel):
model: str
mode: str = "edit" # "edit"(带参考图) | "text"(纯文字)
url: str # /jobs/{job_id}/frames/{idx}/gen/{id}.jpg
+ size: str = ""
+ quality: str = ""
+ width: int = 0
+ height: int = 0
selected: bool = False
created_at: float = 0.0
@@ -4591,23 +4708,69 @@ def image_size_options() -> list[dict]:
return IMAGE_SIZE_CHOICES
+def image_quality_options() -> list[dict]:
+ return IMAGE_QUALITY_CHOICES
+
+
+def _parse_image_dimensions(value: str) -> tuple[int, int] | None:
+ normalized = value.strip().lower().replace("×", "x")
+ m = re.fullmatch(r"(\d{3,4})\s*x\s*(\d{3,4})", normalized)
+ if not m:
+ return None
+ return int(m.group(1)), int(m.group(2))
+
+
+def _validate_custom_image_size(width: int, height: int, raw: str) -> str:
+ pixels = width * height
+ long_edge = max(width, height)
+ short_edge = min(width, height)
+ if width % 16 != 0 or height % 16 != 0:
+ raise HTTPException(400, f"unsupported image size: {raw} (宽高必须都是 16 的倍数,例如 1088x1920)")
+ if long_edge > 3840:
+ raise HTTPException(400, f"unsupported image size: {raw} (最长边不能超过 3840px)")
+ if long_edge / short_edge > 3:
+ raise HTTPException(400, f"unsupported image size: {raw} (画幅比例不能超过 3:1)")
+ if pixels < 655_360 or pixels > 8_294_400:
+ raise HTTPException(400, f"unsupported image size: {raw} (总像素需在 655360 到 8294400 之间)")
+ return f"{width}x{height}"
+
+
def _normalize_image_size(raw: str | None) -> str:
value = (raw or "auto").strip().lower()
aliases = {
+ "9:16": "1088x1920",
+ "9x16": "1088x1920",
+ "16:9": "1280x720",
+ "16x9": "1280x720",
+ "1:1": "1024x1024",
+ "1x1": "1024x1024",
+ "2:3": "1024x1536",
+ "2x3": "1024x1536",
+ "3:2": "1536x1024",
+ "3x2": "1536x1024",
+ "3:4": "960x1280",
+ "3x4": "960x1280",
+ "4:5": "1536x1920",
+ "4x5": "1536x1920",
"vertical": "1024x1536",
"portrait": "1024x1536",
"竖图": "1024x1536",
+ "竖屏": "1088x1920",
"square": "1024x1024",
"方图": "1024x1024",
"horizontal": "1536x1024",
"landscape": "1536x1024",
"横图": "1536x1024",
+ "横屏": "1280x720",
}
value = aliases.get(value, value)
allowed = {str(item["value"]) for item in IMAGE_SIZE_CHOICES}
- if value not in allowed:
- raise HTTPException(400, f"unsupported image size: {raw}")
- return value
+ if value in allowed:
+ return value
+ dimensions = _parse_image_dimensions(value)
+ if dimensions:
+ return _validate_custom_image_size(dimensions[0], dimensions[1], raw or value)
+ raise HTTPException(400, f"unsupported image size: {raw}")
def _image_size_payload(raw: str | None) -> dict:
@@ -4615,6 +4778,39 @@ def _image_size_payload(raw: str | None) -> dict:
return {} if size == "auto" else {"size": size}
+def _normalize_image_quality(raw: str | None) -> str:
+ value = (raw or "high").strip().lower()
+ aliases = {
+ "standard": "high",
+ "hd": "high",
+ "best": "high",
+ "高": "high",
+ "high-quality": "high",
+ "normal": "medium",
+ "regular": "medium",
+ "中": "medium",
+ "medium-quality": "medium",
+ "draft": "low",
+ "fast": "low",
+ "低": "low",
+ "low-quality": "low",
+ }
+ value = aliases.get(value, value)
+ allowed = {str(item["value"]) for item in IMAGE_QUALITY_CHOICES}
+ if value not in allowed:
+ raise HTTPException(400, f"unsupported image quality: {raw}")
+ return value
+
+
+def _image_quality_payload(raw: str | None, model: str | None) -> dict:
+ quality = _normalize_image_quality(raw)
+ return {"quality": quality} if model == GPT_IMAGE_MODEL else {}
+
+
+def _image_options_payload(size: str | None, quality: str | None, model: str | None) -> dict:
+ return {**_image_size_payload(size), **_image_quality_payload(quality, model)}
+
+
def video_duration_options() -> list[int]:
if video_uses_ark():
return [5, 8, 10, 12, 15]
@@ -4774,12 +4970,12 @@ def _image_endpoint(path: str) -> str:
return f"{base}/{path.lstrip('/')}"
-def _image_generation_response(prompt: str, model: str, size: str | None = "auto") -> dict:
+def _image_generation_response(prompt: str, model: str, size: str | None = "auto", quality: str | None = "high") -> dict:
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
r = client.post(
_image_endpoint("/images/generations"),
headers={"Authorization": f"Bearer {IMAGE_API_KEY}"},
- json={"model": model, "prompt": prompt, "n": 1, **_image_size_payload(size)},
+ json={"model": model, "prompt": prompt, "n": 1, **_image_options_payload(size, quality, model)},
)
r.raise_for_status()
return r.json()
@@ -6108,6 +6304,7 @@ def health() -> dict:
"image_request_timeout_seconds": IMAGE_REQUEST_TIMEOUT_SECONDS,
"image_options": image_model_options(),
"image_size_options": image_size_options(),
+ "image_quality_options": image_quality_options(),
"ai_proxy_configured": bool(AI_HTTP_PROXY),
"image_fallbacks": _image_fallback_models(),
"image_circuit": _image_circuit_snapshot(),
@@ -6596,7 +6793,8 @@ class GenerateReq(BaseModel):
extra_prompt: str = "" # ✓ 需要的元素(正向)
negative_prompt: str = "" # ✗ 不需要的元素(负向)
model: str = "auto" # auto / gpt-image-2 / gemini-3-pro-image-preview
- size: str = "auto" # auto / 1024x1536 / 1024x1024 / 1536x1024
+ size: str = "auto" # auto / 1024x1536 / 1088x1920 / custom WxH
+ quality: str = "high" # low / medium / high
mode: str = "edit" # "edit" 带参考图,"text" 纯文字
from_selected: bool = False # True 时优先用 frame.selected 的生成图作 reference(迭代),否则原关键帧
@@ -6634,6 +6832,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
raise HTTPException(400, "prompt required")
full_prompt = _ensure_english(raw_prompt)
image_size = _normalize_image_size(req.size)
+ image_quality = _normalize_image_quality(req.quality)
if not IMAGE_API_KEY:
raise HTTPException(503, "IMAGE_API_KEY 或 LLM_API_KEY 未配置")
@@ -6674,14 +6873,14 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
headers={
"Authorization": f"Bearer {IMAGE_API_KEY}",
},
- data={"model": current_model, "prompt": full_prompt, "n": "1", **_image_size_payload(image_size)},
+ data={"model": current_model, "prompt": full_prompt, "n": "1", **_image_options_payload(image_size, image_quality, current_model)},
files={"image": ("reference.jpg", img_bytes_in, "image/jpeg")},
)
r.raise_for_status()
resp_data = r.json()
else:
# text-only
- resp_data = _image_generation_response(full_prompt, current_model, image_size)
+ resp_data = _image_generation_response(full_prompt, current_model, image_size, image_quality)
if resp_data.get("data"):
effective_mode = f"{current_mode}:{current_model}"
@@ -6746,6 +6945,13 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
gen_dir.mkdir(parents=True, exist_ok=True)
out_path = gen_dir / f"{idx:03d}_{gen_id}.jpg"
out_path.write_bytes(out_bytes)
+ actual_width = 0
+ actual_height = 0
+ try:
+ with Image.open(io.BytesIO(out_bytes)) as im:
+ actual_width, actual_height = im.size
+ except Exception:
+ pass
new_gen = GeneratedImage(
id=gen_id,
@@ -6753,6 +6959,10 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
model=model,
mode=effective_mode,
url=f"/jobs/{job_id}/frames/{idx}/gen/{gen_id}.jpg",
+ size=image_size,
+ quality=image_quality,
+ width=actual_width,
+ height=actual_height,
selected=False,
created_at=_time.time(),
)
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 309ff80..0a4269a 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -584,8 +584,9 @@
2026-05-26 公司沉淀版: 画布项目从浏览器本地存储升级为服务端 Postgres 持久化;localStorage 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 state.json 文件作为任务详情真源,避免一次迁移动到大文件资产结构。
2026-05-26 AI 润色中性化: 画布 AI 润色 不再复用 SKG 广告文案接口 /creative/copy。后端新增 POST /prompt/polish,前端 useChat、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。
2026-05-26 我的工作流云端版: 工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。
+ 2026-05-26 生图规格可视化版: 画布生图配置把“比例”和“真实像素”拆开显示,尺寸下拉改为 竖屏 9:16 · 1088×1920 这类标签,并保留自定义 宽x高 输入;画质从旧的单一“标准”改为 低 / 中 / 高。后端按 GPT Image 2 的像素约束校验自定义尺寸,并把请求尺寸、画质和实际输出像素写回生成图片节点。
- 当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image、/jobs/{id}/frames/{idx}/generate、/jobs/{id}/frames/{idx}/storyboard/video,AI 润色和通用 LLM 文本生成走 /prompt/polish 并保持中性专业:不主动套入 SKG,不主动补产品、平台、广告语境或人物,只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节;视频提交若带参考图,会在最终提示词中条件声明“参考图里若有人物,应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片尺寸只显示 auto、1024x1536、1024x1024、1536x1024;视频画幅只显示 720x1280、1280x720、1024x1024、960x1280;视频时长只显示 5/8/10/12/15 秒。多人互不影响依赖后端 owner_id、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。
+ 当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image、/jobs/{id}/frames/{idx}/generate、/jobs/{id}/frames/{idx}/storyboard/video,AI 润色和通用 LLM 文本生成走 /prompt/polish 并保持中性专业:不主动套入 SKG,不主动补产品、平台、广告语境或人物,只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节;视频提交若带参考图,会在最终提示词中条件声明“参考图里若有人物,应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片配置显示 低 / 中 / 高 画质和带真实像素的尺寸标签,可选 1024x1536、1088x1920、1440x2560、2048x2048 等预设,也可输入符合约束的自定义 宽x高;生成结果节点会显示实际输出像素。视频画幅只显示 720x1280、1280x720、1024x1024、960x1280;视频时长只显示 5/8/10/12/15 秒。多人互不影响依赖后端 owner_id、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。
01
个人任务 GET /jobs 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。
02
进入画布 用户直接在根域名个人画布里操作;项目列表优先读取服务端 /canvas-projects,本地旧项目会首次导入。
@@ -638,7 +639,7 @@
web/components/product-library-picker.tsxSKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 asset。
web/components/storyboard-bar.tsx顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。
web/components/storyboard-workbench.tsx顶部分镜编排条下方的明细区:4 图槽、改造目标、时长、自动保存。
-
web/lib/api.ts前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源,并同步承接 RuntimeSizeOption、图片尺寸、视频画幅、视频时长和最大单条秒数。GeneratedVideo 额外承接 queue_position、queue_size 和 queue_message,用于首页和后续个人画布显示视频生成队列。默认首页主要使用 createCreativeImageJob、uploadReferenceFrame、generateImage 和 generateStoryboardVideo;generateImage 请求体现在可传 size。generateCreativeCopy 仍保留给详情页和后续高级能力。资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。
+
web/lib/api.ts前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源,并同步承接 RuntimeSizeOption、RuntimeQualityOption、图片尺寸、图片画质、视频画幅、视频时长和最大单条秒数。GeneratedImage 额外承接 size、quality、width、height,用于画布和旧首页显示请求规格与实际输出像素;GeneratedVideo 额外承接 queue_position、queue_size 和 queue_message,用于首页和后续个人画布显示视频生成队列。默认首页主要使用 createCreativeImageJob、uploadReferenceFrame、generateImage 和 generateStoryboardVideo;generateImage 请求体可传 size 和 quality。generateCreativeCopy 仍保留给详情页和后续高级能力。资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。
@@ -646,7 +647,7 @@
后端核心
- api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_library 和 asset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 _strip_previous_polish_boilerplate 去掉旧模板尾巴,再用 _classify_prompt_intent 判断人物、无人、物体、场景、动物或未知主体,最后用 _repair_polished_prompt 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;_append_reference_image_person_guard 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;/health 返回 database、image_options、image_size_options、video_options、video_size_options、video_duration_options 和 video_max_duration_seconds;/frames/{idx}/generate 的 model 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2、VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_position、queue_size、queue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
+ api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_library 和 asset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 _strip_previous_polish_boilerplate 去掉旧模板尾巴,再用 _classify_prompt_intent 判断人物、无人、物体、场景、动物或未知主体,最后用 _repair_polished_prompt 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;_append_reference_image_person_guard 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;/health 返回 database、image_options、image_size_options、image_quality_options、video_options、video_size_options、video_duration_options 和 video_max_duration_seconds;/frames/{idx}/generate 的 model 字段用于图片模型偏好,size 字段用于图片输出尺寸或自定义像素,quality 字段用于 low / medium / high 画质;生成完成会把 GeneratedImage.size、quality、width、height 写回。/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2、VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_position、queue_size、queue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
api/db.pyPostgres 适配层:在 DATABASE_URL 存在且 psycopg 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD,以及把 Job、AgentRun、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled,生产 verify-prod-docker.sh 会要求 database.connected=true。
video_model_options()视频模型能力出口:如果 seedance、kling、veo3、veo 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 doubao-seedance-2-0-fast-260128,前端显示为 Seedance 2.0 Fast。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布。
api/product_library/skg-products内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。
@@ -842,6 +843,16 @@ audit_events
elements: KeyElement[],
storyboard: StoryboardScene,
generated_images: GeneratedImage[]
+}
+
+
+
GeneratedImage
+
画布和详情页的图片生成结果。size / quality 是用户请求的规格,width / height 是实际返回图片像素;当用户选择自动尺寸或上游模型调整输出时,以实际像素作为最终展示依据。
+
GeneratedImage {
+ id, prompt, model, mode, url,
+ size, quality,
+ width, height,
+ selected, created_at
}
@@ -1102,12 +1113,12 @@ ProductRefStateItem {
网页登录 / 飞书免登录 GET /auth/config、GET /auth/feishu/start、GET /auth/feishu/callback、POST /auth/login、GET /auth/check、GET /auth/me、POST /auth/logoutweb/app/login/page.tsx、Nginx auth_request登录页先读 /api/auth/config 判断是否显示飞书按钮和密码表单;飞书客户端内且 feishu_enabled=true 时前端自动跳转授权入口,普通浏览器保留手动飞书按钮。飞书 OAuth 成功后后端用 open_id / union_id / email 生成多用户会话并设置 HttpOnly Cookie。当前生产 PASSWORD_AUTH_ENABLED=false,因此 password_enabled=false,账号密码表单不展示,POST /auth/login 返回未配置。生产 Nginx 对工作台和 /api/ 调 /auth/check 做统一校验,未登录页面跳 /login/?next=$request_uri,API 返回 JSON 401。
- 运行配置 / 模型标注 GET /healthgetRuntimeHealth、ModelTrace返回 database 健康状态和 models:ASR、asr_language(默认 auto,表示中文/英文/多语言自动识别)、asr_base_url、asr_remote_enabled、asr_local_fallback_enabled、asr_audio_fallback_enabled、faster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、主图像模型 gpt-image-2、图片故障兜底 image_fallbacks、图片尺寸 image_size_options、短时熔断状态 image_circuit、主体 6 视图模型链路、Azure OpenAI TTS、视频别名、视频画幅 video_size_options、真实可用视频时长 video_duration_options、单条最大秒数 video_max_duration_seconds 和 Seedance 服务商。当前 REWRITE_MODEL、AUDIO_REWRITE_MODEL 和 VISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key、数据库密码或敏感凭证。
+ 运行配置 / 模型标注 GET /healthgetRuntimeHealth、ModelTrace返回 database 健康状态和 models:ASR、asr_language(默认 auto,表示中文/英文/多语言自动识别)、asr_base_url、asr_remote_enabled、asr_local_fallback_enabled、asr_audio_fallback_enabled、faster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、主图像模型 gpt-image-2、图片故障兜底 image_fallbacks、图片尺寸 image_size_options、图片画质 image_quality_options、短时熔断状态 image_circuit、主体 6 视图模型链路、Azure OpenAI TTS、视频别名、视频画幅 video_size_options、真实可用视频时长 video_duration_options、单条最大秒数 video_max_duration_seconds 和 Seedance 服务商。当前 REWRITE_MODEL、AUDIO_REWRITE_MODEL 和 VISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key、数据库密码或敏感凭证。
历史列表 GET /jobslistJobs当前登录用户可见 job 精简列表(id/url/status/thumbnail/mtime/owner…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填本人历史;带 limit 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。
创建任务 POST /jobscreateJob提交 TK 链接,后台开始下载;后端会把当前登录用户写入 Job.owner_*,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies;生产环境必须显式保持 YTDLP_COOKIES_FILE= 和 YTDLP_COOKIES_FROM_BROWSER= 为空,避免容器内误读被打进镜像的开发 api/.env。
画布项目 GET /canvas-projectsPOST /canvas-projectsPUT /canvas-projects/{id}GET /canvas-projects/{id}DELETE /canvas-projects/{id}POST /canvas-projects/importweb/canvas-app/src/stores/projects.js根域名画布项目的服务端持久化接口。列表和详情按当前登录用户过滤;写入时保存画布 JSON、缩略图、可见性、版本和更新时间;删除为软删除。首次上线后本地 localStorage 旧项目会通过 import 导入到当前用户,之后服务端 Postgres 是主存储。
我的工作流 GET /canvas-workflowsPOST /canvas-workflowsPUT /canvas-workflows/{id}DELETE /canvas-workflows/{id}web/canvas-app/src/stores/workflows.jsWorkflowPanel.vue工作流面板“我的工作流”页的个人模板接口。列表、保存、更新和删除都按当前登录用户过滤;保存的是清理过运行态的 workflow_data.nodes/edges/viewport,用于跨设备复用画布结构。插回画布时前端会按当前视口中心重排节点、重建节点 ID,并用旧 ID 到新 ID 的映射重连边,避免和现有画布节点冲突。
- 画布生成 POST /creative/jobs/imagePOST /jobs/{id}/frames/uploadPOST /jobs/{id}/frames/{idx}/generatePOST /jobs/{id}/frames/{idx}/storyboard/videoGET /jobs/{id}web/canvas-app/src/hooks/useApi.js画布项目结构保存在 /canvas-projects;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 skg:{jobId}:{videoId} 作为画布侧任务 id 轮询 /jobs/{id},直到视频状态完成或失败。视频任务最终 prompt 会条件说明参考图人物是 AI 生成的虚拟角色,避免员工上传的 AI 人像素材被当成真实肖像处理。
+ 画布生成 POST /creative/jobs/imagePOST /jobs/{id}/frames/uploadPOST /jobs/{id}/frames/{idx}/generatePOST /jobs/{id}/frames/{idx}/storyboard/videoGET /jobs/{id}web/canvas-app/src/hooks/useApi.js画布项目结构保存在 /canvas-projects;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片,提交 model、size 和 quality;图生图会把参考图片作为 edit 输入并沿用同一套规格。后端返回的 GeneratedImage 带请求尺寸、画质和实际 width×height,画布图片节点据此显示真实像素。图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 skg:{jobId}:{videoId} 作为画布侧任务 id 轮询 /jobs/{id},直到视频状态完成或失败。视频任务最终 prompt 会条件说明参考图人物是 AI 生成的虚拟角色,避免员工上传的 AI 人像素材被当成真实肖像处理。
AI 润色 / LLM 节点 POST /prompt/polishweb/canvas-app/src/hooks/useApi.jsweb/canvas-app/src/api/chat.js中性的提示词润色和通用文本生成接口。根画布和文本节点传 mode=image、默认输出英文提示词;LLM 节点和自动执行意图分析传 mode=chat、保持输入语言。接口会清掉上一次润色遗留的模板尾巴,只保留用户明确写出的主体、品牌、产品、地点、平台、风格和意图;用户没写 SKG 时绝不主动加入 SKG,也不主动补产品、平台、广告语境、slogan 或 hashtag。人物安全词按输入条件加入:原文明确有人像、模特、角色、数字人等语义时才声明“虚构 AI 角色、非真人、非公众人物”;原文明确无人时才保留无人物约束;原文没写人时不主动造人,也不主动追加无人物禁令;输入提到参考图、首帧或尾帧时,提示词只条件保留已有可见人物,不凭空新增人物。
一键出片终端 POST /agent-runsGET /agent-runsGET /agent-runs/{id}GET /agent-runs/{id}/final.mp4GET /agent-runs/{id}/contact.jpgweb/app/agent/page.tsx快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 Job 与 AgentRun,后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。
重试下载 POST /jobs/{id}/download/retryretryJobDownload用于 TK 链接下载失败且没有 video_url 的素材;清空错误、重新进入下载状态,并在后台再次执行 pipeline_download。上传视频不能重下载,需要重新上传文件。
@@ -1149,7 +1160,7 @@ ProductRefStateItem {
AI 改文案 POST /jobs/{job_id}/frames/{idx}/storyboard/refinerefineStoryboard输入当前三字段和中文反馈,返回新的三字段中英镜像。前端必须先弹改前/改后预览,用户点应用后才写入行状态。
单条视频候选生成 POST /jobs/{job_id}/frames/{idx}/storyboard/videogenerateStoryboardVideo新增 count、seed 和 storyboard_row_idx,默认一次创建 4 个 GeneratedVideo 任务并立即返回 job;每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。最终提交给视频模型前,后端会为参考图追加 AI 虚拟角色条件提示:参考图若包含人物、脸、身体、手、头像或角色,就按虚构 AI 角色处理,不按真人或公众人物处理。视频候选显示必须优先按 storyboard_row_idx 归属到音频分镜行,而不是只按 frame_idx。
整片一键生成候选 POST /jobs/{job_id}/storyboard/batch-generate-all当前主路径改为逐行调用 generateStoryboardVideo 用户选择“每行 N 条”后,前端按音频分镜逐行提交,确保每个候选都带 storyboard_row_idx。后端批量接口保留为兼容能力,默认 concurrency=1,但当前 UI 不再用它做主路径。
- 生图 POST /frames/{idx}/generategenerateImage基于关键帧或已选生成图做 image-to-image,目前可用。
+ 生图 POST /frames/{idx}/generategenerateImage基于关键帧、已选生成图或空白 creative job 做图生图 / 文生图;请求体支持 model、size、quality,返回结果写入请求规格和实际输出像素。
@@ -1266,6 +1277,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-26 · 生图配置显示真实尺寸和中高低画质
+ Backend
+ Canvas
+ Data
+
+
+
问题: 画布生图配置只显示“标准画质”和少量比例化尺寸,员工无法在生成前判断实际会出多大的图;生成后图片节点也只显示模型名,不显示请求尺寸或实际像素。
+
改动: api/main.py 扩展 IMAGE_SIZE_CHOICES,新增带 width、height、ratio 的像素预设和自定义 宽x高 校验;GenerateReq 新增 quality,支持 low / medium / high,并把 GeneratedImage.size、quality、width、height 写回。ImageConfigNode.vue 的尺寸下拉改为“比例 · 像素”标签,增加自定义尺寸输入,画质下拉改为低/中/高;ImageNode.vue 显示模型、画质和实际输出像素。
+
影响: 生图人员能在提交前看到真实像素规格,必要时输入自定义尺寸;画质切换不会再重置尺寸。Gemini 仍通过当前 SKG 兼容网关提交,界面会显示请求规格,最终以返回图片的实际像素为准。
+
+
2026-05-26 · AI 润色改为意图分类和冲突校验
diff --git a/web/app/page.tsx b/web/app/page.tsx
index f21f330..5e5c1c7 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -101,10 +101,11 @@ function isVideoMode(mode: CreationMode) {
}
const DEFAULT_IMAGE_SIZE_OPTIONS: RuntimeSizeOption[] = [
- { id: "1024x1536", label: "竖图 2:3", value: "1024x1536" },
- { id: "1024x1024", label: "方图 1:1", value: "1024x1024" },
- { id: "1536x1024", label: "横图 3:2", value: "1536x1024" },
- { id: "auto", label: "自动", value: "auto" },
+ { id: "1024x1536", label: "竖图 2:3 · 1024×1536", value: "1024x1536" },
+ { id: "1088x1920", label: "竖屏 9:16 · 1088×1920", value: "1088x1920" },
+ { id: "1024x1024", label: "方图 1:1 · 1024×1024", value: "1024x1024" },
+ { id: "1536x1024", label: "横图 3:2 · 1536×1024", value: "1536x1024" },
+ { id: "auto", label: "自动 · 生成后显示实际像素", value: "auto" },
]
const DEFAULT_VIDEO_SIZE_OPTIONS: RuntimeSizeOption[] = [
diff --git a/web/canvas-app/src/components/nodes/ImageConfigNode.vue b/web/canvas-app/src/components/nodes/ImageConfigNode.vue
index 654df97..36a6702 100644
--- a/web/canvas-app/src/components/nodes/ImageConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/ImageConfigNode.vue
@@ -61,18 +61,35 @@
-
-
尺寸
-
-
-
- {{ displaySize }}
-
-
-
-
-
+
+
+
尺寸
+
+
+
+ {{ displaySize }}
+
+
+
+
+
+
+
+
+
+ 应用
+
+
+ {{ customSizeError }}
@@ -166,7 +183,7 @@ import { useImageGeneration } from '../../hooks'
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
import { useModelStore } from '../../stores/pinia'
-import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE } from '../../stores/models'
+import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_QUALITY } from '../../stores/models'
import { parseMentions } from '../../hooks/useNodeRef'
// 使用 Pinia store 获取模型选项(根据渠道过滤)
@@ -187,10 +204,16 @@ const isConfigured = computed(() => !!modelStore.currentApiKey)
const { loading, error, images: generatedImages, generate } = useImageGeneration()
// Local state | 本地状态
+const normalizeQualityKey = (quality) => {
+ if (quality === 'standard' || quality === 'hd') return 'high'
+ return quality || DEFAULT_IMAGE_QUALITY
+}
const showHandleMenu = ref(false)
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
const localSize = ref(props.data?.size || DEFAULT_IMAGE_SIZE)
-const localQuality = ref(props.data?.quality || 'standard')
+const localQuality = ref(normalizeQualityKey(props.data?.quality))
+const customSizeInput = ref(localSize.value)
+const customSizeError = ref('')
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
@@ -260,7 +283,7 @@ const hasQualityOptions = computed(() => {
// Display quality | 显示画质
const displayQuality = computed(() => {
const option = qualityOptions.value.find(o => o.key === localQuality.value)
- return option?.label || '标准画质'
+ return option?.label || '高 · 最终稿'
})
// Size options based on model and quality | 基于模型和画质的尺寸选项
@@ -277,9 +300,36 @@ const hasSizeOptions = computed(() => {
// Display size with label | 显示尺寸(带标签)
const displaySize = computed(() => {
const option = sizeOptions.value.find(o => o.key === localSize.value)
- return option?.label || localSize.value
+ return option?.label || formatSizeLabel(localSize.value)
})
+const parseImageSize = (value) => {
+ const normalized = String(value || '').trim().toLowerCase().replace('×', 'x')
+ const match = normalized.match(/^(\d{3,4})\s*x\s*(\d{3,4})$/)
+ if (!match) return null
+ return { width: Number(match[1]), height: Number(match[2]), key: `${Number(match[1])}x${Number(match[2])}` }
+}
+
+const formatSizeLabel = (value) => {
+ if (value === 'auto') return '自动 · 生成后显示实际像素'
+ const parsed = parseImageSize(value)
+ return parsed ? `自定义 · ${parsed.width}×${parsed.height}` : value
+}
+
+const validateImageSize = (value) => {
+ const parsed = parseImageSize(value)
+ if (!parsed) return { ok: false, message: '格式用 1088x1920' }
+ const { width, height } = parsed
+ const pixels = width * height
+ const longEdge = Math.max(width, height)
+ const shortEdge = Math.min(width, height)
+ if (width % 16 !== 0 || height % 16 !== 0) return { ok: false, message: '宽高需为 16 的倍数' }
+ if (longEdge > 3840) return { ok: false, message: '最长边不能超过 3840px' }
+ if (longEdge / shortEdge > 3) return { ok: false, message: '比例不能超过 3:1' }
+ if (pixels < 655360 || pixels > 8294400) return { ok: false, message: '总像素超出模型范围' }
+ return { ok: true, key: parsed.key }
+}
+
// Initialize on mount | 挂载时初始化
onMounted(() => {
// 检查当前模型是否在可用模型列表中
@@ -292,6 +342,9 @@ onMounted(() => {
localModel.value = selected || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
updateNode(props.id, { model: localModel.value })
}
+ if (props.data?.quality !== localQuality.value) {
+ updateNode(props.id, { quality: localQuality.value })
+ }
})
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1)并收集图片
@@ -493,6 +546,8 @@ const handleModelSelect = (key) => {
}
localSize.value = defaultSize
+ customSizeInput.value = defaultSize === 'auto' ? '' : defaultSize
+ customSizeError.value = ''
// 更新节点数据
updateNode(props.id, {
@@ -505,20 +560,14 @@ const handleModelSelect = (key) => {
// Handle quality selection | 处理画质选择
const handleQualitySelect = (quality) => {
localQuality.value = quality
- // Update size to first option of new quality | 更新尺寸为新画质的第一个选项
- const newSizeOptions = getModelSizeOptions(localModel.value, quality)
- if (newSizeOptions.length > 0) {
- const defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
- localSize.value = defaultSize || newSizeOptions[0].key
- updateNode(props.id, { quality, size: localSize.value })
- } else {
- updateNode(props.id, { quality })
- }
+ updateNode(props.id, { quality })
}
// Handle size selection | 处理尺寸选择
const handleSizeSelect = (size) => {
localSize.value = size
+ customSizeInput.value = size === 'auto' ? '' : size
+ customSizeError.value = ''
updateNode(props.id, { size })
}
@@ -527,6 +576,18 @@ const updateSize = () => {
updateNode(props.id, { size: localSize.value })
}
+const applyCustomSize = () => {
+ const result = validateImageSize(customSizeInput.value)
+ if (!result.ok) {
+ customSizeError.value = result.message
+ return
+ }
+ customSizeError.value = ''
+ localSize.value = result.key
+ customSizeInput.value = result.key
+ updateNode(props.id, { size: result.key })
+}
+
// Created image node ID | 创建的图片节点 ID
const createdImageNodeId = ref(null)
@@ -596,7 +657,16 @@ const handleGenerate = async (mode = 'auto') => {
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
imageNodeId = findConnectedOutputImageNode(false)
if (imageNodeId) {
- updateNode(imageNodeId, { loading: true, url: '' })
+ updateNode(imageNodeId, {
+ loading: true,
+ url: '',
+ error: '',
+ model: localModel.value,
+ size: localSize.value,
+ quality: localQuality.value,
+ width: 0,
+ height: 0
+ })
}
} else if (mode === 'new') {
// New mode: always create new node | 新建模式:始终创建新节点
@@ -605,7 +675,15 @@ const handleGenerate = async (mode = 'auto') => {
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
imageNodeId = findConnectedOutputImageNode(true)
if (imageNodeId) {
- updateNode(imageNodeId, { loading: true })
+ updateNode(imageNodeId, {
+ loading: true,
+ error: '',
+ model: localModel.value,
+ size: localSize.value,
+ quality: localQuality.value,
+ width: 0,
+ height: 0
+ })
}
}
@@ -626,7 +704,10 @@ const handleGenerate = async (mode = 'auto') => {
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
url: '',
loading: true,
- label: '图像生成结果'
+ label: '图像生成结果',
+ model: localModel.value,
+ size: localSize.value,
+ quality: localQuality.value
})
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
@@ -668,7 +749,11 @@ const handleGenerate = async (mode = 'auto') => {
url: result[0].url,
loading: false,
label: '文生图',
- model: localModel.value,
+ model: result[0].model || localModel.value,
+ size: result[0].size || localSize.value,
+ quality: result[0].quality || localQuality.value,
+ width: result[0].width || 0,
+ height: result[0].height || 0,
updatedAt: Date.now()
})
@@ -742,10 +827,25 @@ watch(() => props.data?.model, (newModel) => {
// 同步 Size
if (config?.defaultParams?.size) {
localSize.value = config.defaultParams.size
+ customSizeInput.value = localSize.value === 'auto' ? '' : localSize.value
}
}
})
+watch(() => props.data?.size, (newSize) => {
+ if (newSize && newSize !== localSize.value) {
+ localSize.value = newSize
+ customSizeInput.value = newSize === 'auto' ? '' : newSize
+ }
+})
+
+watch(() => props.data?.quality, (newQuality) => {
+ const normalized = normalizeQualityKey(newQuality)
+ if (normalized !== localQuality.value) {
+ localQuality.value = normalized
+ }
+})
+
// 修复 Vue Flow visibility: hidden 问题
watch(() => props.data, () => {
nextTick(() => {
diff --git a/web/canvas-app/src/components/nodes/ImageNode.vue b/web/canvas-app/src/components/nodes/ImageNode.vue
index c1ca598..6f158de 100644
--- a/web/canvas-app/src/components/nodes/ImageNode.vue
+++ b/web/canvas-app/src/components/nodes/ImageNode.vue
@@ -95,9 +95,9 @@
-
-
- {{ data.model }}
+
+
+ {{ imageMetaText }}
@@ -371,6 +371,21 @@ const isPublic = computed(() => {
return props.data?.publicProps?.name != null && props.data?.publicProps?.name !== ''
})
+const imageMetaText = computed(() => {
+ const parts = []
+ if (props.data?.model) parts.push(props.data.model)
+ if (props.data?.quality) {
+ const qualityMap = { low: '低', medium: '中', high: '高', standard: '高', hd: '高' }
+ parts.push(`${qualityMap[props.data.quality] || props.data.quality}画质`)
+ }
+ if (props.data?.width && props.data?.height) {
+ parts.push(`${props.data.width}×${props.data.height}`)
+ } else if (props.data?.size) {
+ parts.push(String(props.data.size).replace('x', '×'))
+ }
+ return parts.join(' · ')
+})
+
// Handle toggle public | 处理切换公开状态
const handleTogglePublic = (value) => {
if (value) {
@@ -419,6 +434,7 @@ const handleSelect = (item) => {
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
+ quality: 'high',
label: '生图配置'
})
@@ -628,6 +644,7 @@ const createInpaintWorkflow = () => {
const configNodeId = addNode('imageConfig', { x: nodeX + 600, y: nodeY }, {
model: 'auto',
size: '1024x1536',
+ quality: 'high',
label: '局部重绘',
inpaintMode: true
})
@@ -850,6 +867,7 @@ const handleImageGen = () => {
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
+ quality: 'high',
label: '生图配置'
})
diff --git a/web/canvas-app/src/components/nodes/LLMConfigNode.vue b/web/canvas-app/src/components/nodes/LLMConfigNode.vue
index 10dc964..7b48085 100644
--- a/web/canvas-app/src/components/nodes/LLMConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/LLMConfigNode.vue
@@ -417,7 +417,7 @@ const handleSelect = (item) => {
const nodeY = currentNode?.position?.y || 0
const defaultData = {
- imageConfig: { model: 'auto', size: '1024x1536', label: '文生图' },
+ imageConfig: { model: 'auto', size: '1024x1536', quality: 'high', label: '文生图' },
videoConfig: { label: '视频生成' },
text: { content: '', label: '文本输入' }
}
@@ -1008,7 +1008,8 @@ const handleSplitToTextWithImage = () => {
data: {
label: `图片 ${i + 1}`,
model: 'auto',
- size: '1024x1536'
+ size: '1024x1536',
+ quality: 'high'
}
}
nodeSpecs.push(imageConfigSpec)
diff --git a/web/canvas-app/src/components/nodes/TextNode.vue b/web/canvas-app/src/components/nodes/TextNode.vue
index 755a2e2..b7d22d4 100644
--- a/web/canvas-app/src/components/nodes/TextNode.vue
+++ b/web/canvas-app/src/components/nodes/TextNode.vue
@@ -442,7 +442,7 @@ const handleSelect = (item) => {
const nodeY = currentNode?.position?.y || 0
const defaultData = {
- imageConfig: { model: 'auto', size: '1024x1536', label: '文生图' },
+ imageConfig: { model: 'auto', size: '1024x1536', quality: 'high', label: '文生图' },
videoConfig: { label: '视频生成' },
llmConfig: { label: 'LLM文本生成' }
}
@@ -699,6 +699,7 @@ const handleImageGen = () => {
const configNodeId = addNode('imageConfig', { x: nodeX + 400, y: nodeY }, {
model: 'auto',
size: '1024x1536',
+ quality: 'high',
label: '文生图'
})
diff --git a/web/canvas-app/src/config/models.js b/web/canvas-app/src/config/models.js
index 6a85923..56dd326 100644
--- a/web/canvas-app/src/config/models.js
+++ b/web/canvas-app/src/config/models.js
@@ -5,18 +5,28 @@
// SKG backend image size options | SKG 后端图片尺寸选项
export const SEEDREAM_SIZE_OPTIONS = [
- { label: '自动', key: 'auto' },
- { label: '竖图 2:3', key: '1024x1536' },
- { label: '方图 1:1', key: '1024x1024' },
- { label: '横图 3:2', key: '1536x1024' }
+ { label: '自动 · 生成后显示实际像素', key: 'auto', ratio: 'auto' },
+ { label: '竖图 2:3 · 1024×1536', key: '1024x1536', ratio: '2:3', width: 1024, height: 1536 },
+ { label: '竖图 2:3 · 1536×2304', key: '1536x2304', ratio: '2:3', width: 1536, height: 2304 },
+ { label: '竖屏 9:16 · 1088×1920', key: '1088x1920', ratio: '9:16', width: 1088, height: 1920 },
+ { label: '竖屏 9:16 · 1440×2560', key: '1440x2560', ratio: '9:16', width: 1440, height: 2560 },
+ { label: '竖图 3:4 · 960×1280', key: '960x1280', ratio: '3:4', width: 960, height: 1280 },
+ { label: '竖图 4:5 · 1536×1920', key: '1536x1920', ratio: '4:5', width: 1536, height: 1920 },
+ { label: '方图 1:1 · 1024×1024', key: '1024x1024', ratio: '1:1', width: 1024, height: 1024 },
+ { label: '方图 1:1 · 2048×2048', key: '2048x2048', ratio: '1:1', width: 2048, height: 2048 },
+ { label: '横图 3:2 · 1536×1024', key: '1536x1024', ratio: '3:2', width: 1536, height: 1024 },
+ { label: '横图 3:2 · 2304×1536', key: '2304x1536', ratio: '3:2', width: 2304, height: 1536 },
+ { label: '横屏 16:9 · 1280×720', key: '1280x720', ratio: '16:9', width: 1280, height: 720 },
+ { label: '横屏 16:9 · 2048×1152', key: '2048x1152', ratio: '16:9', width: 2048, height: 1152 }
]
// Kept for compatibility with upstream model helpers.
export const SEEDREAM_4K_SIZE_OPTIONS = SEEDREAM_SIZE_OPTIONS
-// SKG backend currently exposes model choice and size; quality is retained as a no-op UI field.
export const SEEDREAM_QUALITY_OPTIONS = [
- { label: '标准', key: 'standard' }
+ { label: '低 · 快速草稿', key: 'low' },
+ { label: '中 · 常规出图', key: 'medium' },
+ { label: '高 · 最终稿', key: 'high' }
]
export const BANANA_SIZE_OPTIONS = [
@@ -39,7 +49,7 @@ export const IMAGE_MODELS = [
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
- quality: 'standard',
+ quality: 'high',
style: 'vivid'
}
},
@@ -51,7 +61,7 @@ export const IMAGE_MODELS = [
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
- quality: 'standard',
+ quality: 'high',
style: 'vivid'
}
},
@@ -63,7 +73,7 @@ export const IMAGE_MODELS = [
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
- quality: 'standard',
+ quality: 'high',
style: 'vivid'
}
},
@@ -118,16 +128,12 @@ export const CHAT_MODELS = [
// Image size options | 图片尺寸选项
export const IMAGE_SIZE_OPTIONS = [
- { label: '自动', key: 'auto' },
- { label: '竖图 2:3', key: '1024x1536' },
- { label: '方图 1:1', key: '1024x1024' },
- { label: '横图 3:2', key: '1536x1024' }
+ ...SEEDREAM_SIZE_OPTIONS
]
// Image quality options | 图片质量选项
export const IMAGE_QUALITY_OPTIONS = [
- { label: '标准', key: 'standard' },
- { label: '高清', key: 'hd' }
+ ...SEEDREAM_QUALITY_OPTIONS
]
// Image style options | 图片风格选项
@@ -153,6 +159,7 @@ export const DEFAULT_IMAGE_MODEL = 'auto'
export const DEFAULT_VIDEO_MODEL = 'seedance'
export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
export const DEFAULT_IMAGE_SIZE = '1024x1536'
+export const DEFAULT_IMAGE_QUALITY = 'high'
export const DEFAULT_VIDEO_RATIO = '720x1280'
export const DEFAULT_VIDEO_DURATION = 10
diff --git a/web/canvas-app/src/hooks/useApi.js b/web/canvas-app/src/hooks/useApi.js
index 52c3942..b2dc021 100644
--- a/web/canvas-app/src/hooks/useApi.js
+++ b/web/canvas-app/src/hooks/useApi.js
@@ -192,6 +192,7 @@ export const useImageGeneration = () => {
prompt: params.prompt || '',
model: params.model || 'auto',
size: params.size || '1024x1536',
+ quality: params.quality || 'high',
mode: firstRef ? 'edit' : 'text'
})
})
diff --git a/web/canvas-app/src/stores/canvas.js b/web/canvas-app/src/stores/canvas.js
index d2c73f3..bf96520 100644
--- a/web/canvas-app/src/stores/canvas.js
+++ b/web/canvas-app/src/stores/canvas.js
@@ -244,7 +244,7 @@ const getDefaultNodeData = (type) => {
prompt: '',
model: DEFAULT_IMAGE_MODEL,
size: imageModel?.defaultParams?.size || '1x1',
- quality: imageModel?.defaultParams?.quality || 'standard',
+ quality: imageModel?.defaultParams?.quality || 'high',
label: '文生图'
}
}
@@ -402,7 +402,8 @@ export const initSampleData = () => {
addNode('imageConfig', { x: 450, y: 150 }, {
prompt: '',
model: 'auto',
- ratio: '16:9 | 4张 | 高清',
+ size: '1024x1536',
+ quality: 'high',
label: '文生图'
})
diff --git a/web/canvas-app/src/stores/models.js b/web/canvas-app/src/stores/models.js
index c0beb02..249cd15 100644
--- a/web/canvas-app/src/stores/models.js
+++ b/web/canvas-app/src/stores/models.js
@@ -19,6 +19,7 @@ import {
DEFAULT_VIDEO_MODEL,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_SIZE,
+ DEFAULT_IMAGE_QUALITY,
DEFAULT_VIDEO_RATIO,
DEFAULT_VIDEO_DURATION
} from '@/config/models'
@@ -66,7 +67,7 @@ export const getModelConfig = (modelKey) => {
* Get size options for image model | 获取图片模型尺寸选项
* Returns options based on model's sizes array and quality
*/
-export const getModelSizeOptions = (modelKey, quality = 'standard') => {
+export const getModelSizeOptions = (modelKey, quality = 'high') => {
const model = IMAGE_MODELS.find(m => m.key === modelKey)
// If model has getSizesByQuality function, use it | 如果模型有 getSizesByQuality 函数,使用它
@@ -202,6 +203,7 @@ export {
DEFAULT_VIDEO_MODEL,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_SIZE,
+ DEFAULT_IMAGE_QUALITY,
DEFAULT_VIDEO_RATIO,
DEFAULT_VIDEO_DURATION
}
diff --git a/web/canvas-app/src/views/Canvas.vue b/web/canvas-app/src/views/Canvas.vue
index 70702e3..edc5254 100644
--- a/web/canvas-app/src/views/Canvas.vue
+++ b/web/canvas-app/src/views/Canvas.vue
@@ -891,6 +891,9 @@ const sendMessage = async () => {
})
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
+ model: 'auto',
+ size: '1024x1536',
+ quality: 'high',
label: '文生图'
})
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 5b23cea..748edac 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -42,6 +42,10 @@ export interface GeneratedImage {
model: string
mode: string
url: string
+ size?: string
+ quality?: string
+ width?: number
+ height?: number
selected: boolean
created_at: number
}
@@ -269,6 +273,16 @@ export interface RuntimeModelOption {
}
export interface RuntimeSizeOption {
+ id: string
+ label: string
+ value: string
+ ratio?: string
+ width?: number
+ height?: number
+ description?: string
+}
+
+export interface RuntimeQualityOption {
id: string
label: string
value: string
@@ -294,6 +308,7 @@ export interface RuntimeModels {
image_base_url?: string
image_options?: RuntimeModelOption[]
image_size_options?: RuntimeSizeOption[]
+ image_quality_options?: RuntimeQualityOption[]
image_fallbacks?: string[]
image_circuit?: {
primary?: string
@@ -1257,7 +1272,7 @@ export async function translateText(text: string, target: "en" | "zh" = "en"): P
export async function generateImage(
jobId: string,
frameIdx: number,
- body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; size?: string; mode?: "edit" | "text"; from_selected?: boolean },
+ body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; size?: string; quality?: string; mode?: "edit" | "text"; from_selected?: boolean },
): Promise {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/generate`, {
method: "POST",