65 KiB
镭射卡 Dify 工作流重构设计(v2 — 5 系列 × 主题叙事)
作者:Claude(brainstorming with user) 创建日期:2026-07-07 关联 commit:139d91d(feat/len 分支) 状态:草案,待用户审 spec
1. 方案概述(必读)
1.1 要解决的问题
业务问题
- 当前用户进入镭射卡生成后看到的是"AI 出图但没有金属镭射光效"的 5 张普通图
- 5 张预设(dream/classic/holoFull/ice/sunset)都是"金属银 + 单一彩虹带",物理上同质,用户感觉"5 张几乎一样"
- 用户倾斜手机时,前端无任何视觉反馈(当前缺前端 WebGL tilt 渲染层)
技术问题
- 前端
useLaserBatchGenerate.js硬编码genMode = 'openai',但代码里同时存在minimax / dify / openai / relay-dify4 个 provider 分支(minimax 是默认 provider 但前端从不触发) - OpenAI
/v1/images/edits路径跳过了 compositor 服务,导致 GPT-image 不擅长金属镭射 → 出图无光效 - 45 风格池(
frontend/utils/laser-card/stylePool.js+frontend/utils/laser-card/gacha.js)整套 dead code,无业务方调用(已删除) - 后端 compositor 服务(
backend/gateway/service/compositor/)有完整的 6 层合成能力,但当前链路不用
1.2 整体实现路径
| 阶段 | 时长 | 内容 |
|---|---|---|
| Phase 1: 锁定 provider | 0.5d | 前端 useLaserBatchGenerate 改 genMode = 'dify';后端默认 LASER_GEN_PROVIDER=dify |
| Phase 2: 搭建 Dify 工作流 | 1d | 按本文档第 5 节配置 laser_card_v2_5series 工作流 |
| Phase 3: 后端适配 | 0.5d | handleDifyBlocking 已存在,只需适配新 variant 字段 |
| Phase 4: 测试 + 灰度 | 1d | 端到端测试 → 灰度切流 → 全量 |
总工作量:约 3 人日(含 Dify 配置调试)
1.3 关键决策
| 决策 | 选择 | 理由 | 详细 |
|---|---|---|---|
| 生成链路 | Dify 工作流统一编排 | 用户选择路径 C;编排逻辑可视化,便于调优 | §3 |
| 5 张差异化 | 5 个卡牌系列 × 主题叙事 | 工艺 + 主题双重差异;用户视觉一眼可辨 | §4 |
| AI 模型(文本) | gpt-4o-mini(推荐)或 minimax-m3(备选) | gpt-4o-mini 便宜稳定;minimax-m3 国内可用但对文化符号理解弱 | §5.3 |
| AI 模型(图像) | gpt-image-1(推荐)或 Dify 平台支持的图像模型 | gpt-image-1 质量高;如不支持换 SDXL/Replicate Flux/通义万相 | §5.4 |
| AI 角色 | AI 出"金属底卡"(金属色 + 风格场景,不含彩虹扫光) | 彩虹由前端 WebGL 实时叠,保证"不同角度不同色彩"可控、稳定 | §3 + §12 |
| 前端叠层 | WebGL tilt 实时渲染(基于 LenticularEngine) | 复用现有引擎;用户倾斜手机时色彩随 viewAngle 偏移 | §12 |
| 后端 compositor | 必须保留并增强 | AI 出金属底 + 后端 compositor 合成"人像 + 镭射光效",最终成品图 | §6.3 + §13 |
| 数据库 | render_config JSONB 沿用 backdrop_tone 字段(不做 schema 变更) |
前端读 backdrop_tone 决定 metal 颜色;新增数据通过现有字段表达 |
§7 |
1.4 核心架构图(TL;DR)
用户上传原图 + 提示词
↓
[前端] useLaserDifyGenerate.submit()
↓ POST /api/v1/laser/generate
↓ body: {cutout_url, preset_codes:[v1..v5], render_configs:[5]}
↓ (用户风格描述词由 Dify 工作流 #context# 注入,无需前端传)
[后端] handleDifyBlocking()
↓ 调 Dify /v1/workflows/run
[Dify] 工作流 laser_card_v2_5series:
Step 1: LLM 节点 (gpt-4o-mini) — 扩写 5 个系列化子 prompt
Step 2: Code 节点 — 拆分 JSON 为 5 变量
Step 3: 5 路并发生图 (gpt-image-1) — 每路 1 张"金属底 + 主题"图
Step 4: 结束节点 — 返回 variants 数组
↓
[后端] 解析 variants + persistGeneratedInstance
↓ 写 laser_card_instances.materials_snapshot
[前端] 5 张图展示 + 用户选 1
↓
┌────────────────────────────┐
│ LenticularCard.vue │
│ ↓ │
│ useLenticularCraftTiltPreview │ ← 陀螺仪 / 触摸 输入
│ ↓ │
│ LenticularEngine (WebGL) │ ← 计算每帧 rainbow offset
│ ↓ │
│ holographic-shaders │ ← shader 输出"色带随 viewAngle 漂"
└────────────────────────────┘
↓ 倾斜手机 → 看到不同色彩
[铸造] craftMintSubmit → 标准铸造流程
2. 当前状态分析
2.1 现有代码资产(保留)
| 文件 | 用途 | 处置 |
|---|---|---|
backend/gateway/controller/laser_generate_controller.go |
POST /api/v1/laser/generate | 保留,仅调整 handleDifyBlocking 解析逻辑 |
backend/gateway/repository/laser_card_repository.go |
DB CRUD | 保留 |
backend/pkg/models/laser_card.go |
模型定义 | 保留 |
frontend/composables/useLaserDifyGenerate.js |
前端主调用 | 保留,调整 render_configs 数量 |
frontend/utils/laser-card/laserPresets.js |
5 套 preset 定义 | 保留,但改名/调整 |
frontend/utils/laser-card/laserGrating.js |
grating 参数 | 保留(grating_config 字段兼容) |
2.2 现有代码资产(废弃)
| 文件 | 处置 | commit |
|---|---|---|
| 4 个 provider 中的 minimax/openai/relay-dify 分支 | 降级为注释,保留 dify | 本次 PR |
backend/gateway/service/compositor/*.go |
标记 DEPRECATED | 后续 PR |
backend/gateway/controller/compositor_controller.go |
标记 DEPRECATED | 后续 PR |
backend/gateway/service/openai_client.go |
保留(兼容字段),但激光卡不再调 | 本次 PR |
3. Dify 工作流设计
3.1 工作流基本信息
| 字段 | 值 |
|---|---|
| 名称 | laser_card_v2_5series |
| 类型 | Workflow(API 触发) |
| 触发方式 | HTTP POST /v1/workflows/run |
| 预计耗时 | 30-60 秒(5 路 AI 生图并发) |
| 输出 | JSON 数组 variants[5] + warnings + cutout_url |
3.2 工作流节点全景图
完整可视化流程图见:dify-laser-workflow.drawio
[开始] → [LLM: 扩写 prompt] → [Code: 拆分 JSON]
↓
并发 5 路(每路 1 个系列)
↓
┌────────┬────────┬────────┬────────┐
↓ ↓ ↓ ↓ ↓
[生图1] [生图2] [生图3] [生图4] [生图5]
Prizm Pokemon Topps YuGiOh Weiss
Blue Rainbow Refr. Secret SSP
↓ ↓ ↓ ↓ ↓
└────────┴────────┴────────┴────────┘
↓
[结束: 输出 variants]
3.3 节点清单(10 个)
| # | 类型 | 名称 | 输入 | 输出 |
|---|---|---|---|---|
| 1 | 开始 | start |
— | cutout_url(用户主题由 Dify 原生 #context# 注入) |
| 2 | LLM | llm_rewrite |
#context#(user message) |
JSON: 5 个系列 prompt |
| 3 | — | 跳过(Dify 自动拆 JSON,见 §5.3) | — | — |
| 4 | 生图 | gen_prizm_blue |
{{ llm_rewrite.prizm_blue}} + cutout_url |
img_1 (signed_url) |
| 5 | 生图 | gen_pokemon_rainbow |
{{ llm_rewrite.pokemon_rainbow}} + cutout_url |
img_2 |
| 6 | 生图 | gen_topps_refractor |
{{ llm_rewrite.topps_refractor}} + cutout_url |
img_3 |
| 7 | 生图 | gen_yugioh_secret |
{{ llm_rewrite.yugioh_secret}} + cutout_url |
img_4 |
| 8 | 生图 | gen_ws_ssp |
{{ llm_rewrite.ws_ssp}} + cutout_url |
img_5 |
| 9 | 代码 | code_merge |
5 个 img URL | variants JSON 数组 |
| 10 | 结束 | end |
variants, warnings, cutout_url | — |
4. 5 个卡牌系列定义
4.1 系列与 preset_id 对应
| preset_id | 中文名 | family | 工艺特征 | metal tone | sheen angle | foil |
|---|---|---|---|---|---|---|
prizm_blue |
蓝棱镜 | panini_prizm | 电光蓝宝石 + 锐利反射 | 冷银 #A8ACB2 | 120° | 0.70 |
pokemon_rainbow |
宝可梦彩虹 | pokemon_tcg | 全光谱彩虹 + 雪花点 | 珍珠银 #B0ACA6 | 100° | 0.90 |
topps_refractor |
Topps 经典 | topps_chrome | 单彩虹带 + 复古胶片 | 冷银 #A8ACB2 | 125° | 0.68 |
yugioh_secret |
游戏王 Secret | yugioh | 全卡横纹扫光 | 珍珠银 #B0ACA6 | 90° | 0.90 |
ws_ssp |
WS SSP | weiss | 日系终极收藏 + 全卡 rainbow | 冷银 #A8ACB2 | 95° | 0.95 |
4.2 Dify 数据库初始化
Dify 工作流的 laser_card_templates 表需要同步 5 条记录(与前端 preset_id 对齐)。SQL 见 §7。
5. Dify 工作流节点配置(小白填写指南)
重要:以下每个节点都标注了"在 Dify 平台怎么找这个节点"。如果你找不到对应类型,问 Dify 平台客服或看他们的官方文档。
5.1 节点 1:开始节点
在 Dify 平台:新建工作流时默认就有;如果已有工作流,在画布左上角点"+",选"开始"。
填写步骤:
- 节点标题:
开始(默认) - 在"输入变量"区域,点"+ 添加变量",只添加 1 个自定义变量(用户主题用 Dify 原生
#context#注入,不要添加自定义变量):
| 变量名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
cutout_url |
文本 (string) | ✓ | — | 用户原图的 OSS signed URL |
-
用户风格描述词不要做变量:Dify 工作流运行时,平台会自动把"用户当前输入消息"注入为
#context#特殊变量。在 LLM 节点 user prompt 字段直接填#context#即可(无需自定义变量)。 -
点"保存"按钮
常见错误:
- 添加了
user_prompt自定义变量 → 多余且容易混淆(LLM 会拿到重复信息) - 必填勾错:所有变量都勾必填会阻止测试运行
5.2 节点 2:LLM 节点(关键节点)
在 Dify 平台:点画布"+",搜索"LLM",选第一个(带大模型图标的)。
填写步骤:
- 节点标题:
llm_rewrite(任意,方便引用) - 模型:下拉选
gpt-4o-mini(便宜、快速;如果你想质量更高可改gpt-4o) - 系统提示 (System Prompt):粘贴下面整段 ↓
你是镭射卡视觉设计师 + 卡牌工艺专家。
【输入说明】用户输入的"风格描述词"会通过 Dify 的 #context# 特殊变量传入到下方 user message 字段。
用户的输入是"镭射卡想要的氛围/风格",例如"梦幻樱花"、"星空极光"、"赛博朋克"、"蒸汽朋克"。
你的任务是:读 user message 中的风格词,把它嵌入到下方 5 个系列的模板中(替换 `{主题}` 占位符)。
每个 prompt 对应一种真实存在的卡牌工艺系列;5 张图都是同一风格、不同工艺底,让用户对比"哪个光效最适合"。
【关键】每张图只需要出"金属底 + 风格氛围",不要出彩虹扫光带!
彩虹扫光带由前端 WebGL 根据用户倾斜角度实时叠加,与 AI 出的图无关。
AI 出图只需要保证:金属底色 + 风格氛围(场景/色彩/质感),让前端去做"光感"。
【模板占位符约定】下方 5 个系列模板中的 `{主题}` 是占位符,**你在输出时必须把 `{主题}` 替换为 user message 中的实际风格词**,不要保留 `{主题}` 字面量。
## 【系统 prompt 内部小节】5 个卡牌系列(每张的金属底 + 工艺特征严格对齐下方)
> **针对 MiniMax image-01 优化版**:由于 MiniMax 不支持 Negative Prompt、且对"金属 + 镭射"理解较弱,模板增加了**视觉强约束词**和**摄影术语**以提升出图质量。
### Series 1: Prizm Blue (美式运动卡蓝棱镜)
- 金属底:冷银 #A8ACB2,偏蓝宝石色
- 主题特征:锐利高对比、运动卡质感、人像居中、运动场景元素
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 brushed metal card background, electric blue sapphire accent, sharp reflective sport card aesthetic, centered subject, 50mm lens, photorealistic, 4k, detailed, no text, no people, no watermark"
### Series 2: Pokemon Rainbow Rare (宝可梦全谱彩虹)
- 金属底:珍珠银 #B0ACA6,柔和高亮
- 主题特征:日系动漫风、可爱人物、闪闪发光背景、精致细节
- bg_prompt 模板:"{主题}, on pearl silver #B0ACA6 lustrous metal card background, soft luminous anime glow, premium Japanese TCG aesthetic, centered chibi-style subject, vibrant pastel sparkles, 4k illustration, detailed, no text, no people, no watermark"
### Series 3: Topps Refractor (棒球经典折射)
- 金属底:冷银 #A8ACB2,复古胶片颗粒
- 主题特征:复古运动卡、肖像构图、专业感、暗角
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 vintage chrome card background, vintage baseball card aesthetic, soft film grain texture, classic portrait composition, vignette edges, analog warmth, professional studio lighting, 4k, photorealistic, no text, no people, no watermark"
### Series 4: Yu-Gi-Oh Secret (游戏王 Secret 横纹)
- 金属底:珍珠银 #B0ACA6,全卡面爆闪
- 主题特征:日系动漫、神秘角色、魔法阵元素、动态构图
- bg_prompt 模板:"{主题}, on pearl silver #B0ACA6 full-card luminous metal background, dynamic horizontal stripe holographic shine, magic circle sigils beneath, dynamic action pose, anime TCG secret-tier aesthetic, 4k illustration, detailed, no text, no people, no watermark"
### Series 5: Weiss SSP (WS 终极签名)
- 金属底:冷银 #A8ACB2,极致金属光泽
- 主题特征:日系动漫终极收藏、签名感、豪华边框感
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 premium polished metal card background, signature-style framed composition, anime collectible apex aesthetic, luxurious engraved border details, ultra-polished mirror surface, 4k illustration, detailed, no text, no people, no watermark"
### 优化点说明(MiniMax 适配)
| 改动 | 原因 |
|---|---|
| 加 `brushed metal / lustrous / vintage chrome / polished` | 强调物理金属质感(MiniMax 对此敏感) |
| 加 `50mm lens / photorealistic / 4k / detailed` | 摄影/质量术语提升写实度 |
| 加 `centered subject` | 强制主体居中(避免 AI 随意构图) |
| 加 `vignette edges / analog warmth`(Topps) | 复古感细节 |
| 加 `vibrant pastel sparkles`(Pokemon) | 闪光粒子细节 |
| 加 `dynamic horizontal stripe`(YGO) | 横纹扫光(AI 能直接渲,不用前端叠) |
| 加 `engraved border details`(WS) | 雕刻边框细节 |
**前提**:因为 MiniMax 不支持 Negative Prompt,所以"no text, no people, no watermark"必须塞进**每个 prompt 末尾**作为正向约束(LLM 强制约束已要求)。
## 【prompt 内部】输出格式(严格 JSON)
{
"prizm_blue": "<英文 prompt>",
"pokemon_rainbow": "<英文 prompt>",
"topps_refractor": "<英文 prompt>",
"yugioh_secret": "<英文 prompt>",
"ws_ssp": "<英文 prompt>"
}
## 【prompt 内部】强制约束
1. 用户输入的"风格描述词"必须出现在 5 个 prompt 里(替换 `{主题}` 占位符)
2. 中文风格词翻译成地道的英文后再嵌入(如"梦幻樱花" → "dreamy cherry blossom";"星空极光" → "starry aurora";"赛博朋克" → "cyberpunk neon city")
3. 每个 prompt 末尾必须包含 "no text, no people, no watermark"
4. 金属底色必须严格对齐上方 hex 数值(#A8ACB2 / #B0ACA6)
5. 5 个 prompt 之间必须有明显的视觉差异(不能换汤不换药)
6. 【严禁】出现 "holographic / rainbow / sheen / foil / refractive / prismatic / iridescent" 等词
(前端 WebGL 会处理这些,AI 不要画蛇添足)
7. 禁止 "flat / matte / dull / cartoon / drawing / 2D / sketch" 等削弱金属感的词
8. 必须强调金属感:使用 "metallic / lustrous / polished / brushed metal / chrome / silver substrate" 等词
- 用户提示 (User Prompt):填
#context#(Dify 原生特殊变量,不是{{...}}形式)- 怎么填:直接在用户提示框打
#context#(Dify 平台会识别这个特殊变量,自动注入用户当前消息) - 也可写:
用户的主题是:#context#(更明确)
- 怎么填:直接在用户提示框打
- 输出格式:选 JSON(重要!不选 JSON 后续 Code 节点没法解析)
- 点"保存"
常见错误:
- 输出格式忘记选 JSON → 后面的 Code 节点解析失败
- 模板占位符没有让 LLM 替换(如保留
{主题}字面量输出)→ 生图节点会拿到{主题}字符串而报错 - 模型选了太贵的(如 gpt-4-vision-preview)→ 费用爆炸
- 没限定用户输入是"风格/氛围词" → 用户可能输入 IP 名(如 Pokemon),AI 不知道怎么处理
5.3 节点 3:跳过(当前生产方案)
当前生产方案:不创建代码节点 3,让 5 个生图节点直接引用 LLM 的 JSON 子字段。完全避开 Dify sandbox(当前线上 sandbox 0.2.15 有 DifySeccomp abort bug,Python 代码节点无法运行)。
连线:
[LLM llm_rewrite] (输出格式=JSON)
↓
├─→ [生图 4] prompt = {{ llm_rewrite.prizm_blue}}
├─→ [生图 5] prompt = {{ llm_rewrite.pokemon_rainbow}}
├─→ [生图 6] prompt = {{ llm_rewrite.topps_refractor}}
├─→ [生图 7] prompt = {{ llm_rewrite.yugioh_secret}}
└─→ [生图 8] prompt = {{ llm_rewrite.ws_ssp}}
Dify 平台操作步骤:
-
配置 LLM 节点:在
llm_rewrite节点编辑界面:-
系统提示字段:粘贴 spec §5.2 完整中文 prompt
-
用户提示字段:填
#context# -
滚到下方"输出变量"区域,打开"结构化输出"开关
-
在 JSON Schema 输入框粘贴:
{ "type": "object", "properties": { "prizm_blue": { "type": "string" }, "pokemon_rainbow": { "type": "string" }, "topps_refractor": { "type": "string" }, "yugioh_secret": { "type": "string" }, "ws_ssp": { "type": "string" } }, "required": ["prizm_blue", "pokemon_rainbow", "topps_refractor", "yugioh_secret", "ws_ssp"] }
-
-
修改每个生图节点的 Prompt 字段(用变量选择器选,不要手敲):
| 节点 | Prompt 字段 |
|---|---|
gen_prizm_blue |
用 / 选 → llm_rewrite.structured_output.prizm_blue |
gen_pokemon_rainbow |
llm_rewrite.structured_output.pokemon_rainbow |
gen_topps_refractor |
llm_rewrite.structured_output.topps_refractor |
gen_yugioh_secret |
llm_rewrite.structured_output.yugioh_secret |
gen_ws_ssp |
llm_rewrite.structured_output.ws_ssp |
- 不要创建任何代码节点
- 保存 + 测试
为什么这样能跑通:
- ✓ 完全不调 Python sandbox → 避开
DifySeccomp abortbug - ✓ Dify 1.x 结构化输出自动 parse → 用平台验证过的功能
- ✓ 不需要 import json / json.loads → 减少代码出错面
前提条件:
- LLM 节点系统提示是 spec §5.2 完整中文 prompt
- LLM 节点打开"结构化输出"开关
- 用户提示字段填
#context#
常见错误:
- LLM 节点没打开"结构化输出" → 下游拿不到字段,路径语法失败
- LLM 系统提示用了对话式英文模板(不是 spec §5.2 中文版)→ LLM 输出问句而不是 JSON
- 路径语法写错:
llm_rewrite.structured_output.prizm_blue是正确的,不是.json. - JSON Schema 字段名拼错:必须严格是
prizm_blue/pokemon_rainbow等
5.3.1 备用方案:用代码节点(沙箱修复后启用)
何时启用:等 Dify sandbox 升级到 0.2.16+ 或 Dify 平台提供新 sandbox 镜像,Python 代码节点能稳定运行后,再启用此方案作为冗余。
当前状态:仅作参考,不要在生产中部署(线上 sandbox 0.2.15 会 abort)。
实现思路:
在 LLM 节点和生图节点之间加一个 code_split 代码节点:
import json
def main(llm_output: str) -> dict:
cleaned = llm_output.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("```")[1]
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
return {
"p1": data.get("prizm_blue", ""),
"p2": data.get("pokemon_rainbow", ""),
"p3": data.get("topps_refractor", ""),
"p4": data.get("yugioh_secret", ""),
"p5": data.get("ws_ssp", ""),
}
输出 5 个变量 p1 ~ p5,生图节点引用 {{ code_split.p1}} ... {{ code_split.p5}}。
操作:
- 在 LLM 节点配置界面勾选**"输出格式 = JSON"**(Dify 会自动 parse)
- 在生图节点 4 的 Prompt 字段打:
{{ llm_rewrite.prizm_blue}} - 其他 4 个生图节点类似,分别引用不同 key
- 删掉原来的 code_split 节点
- 测试运行
为什么能跑通:
- 完全不调 Python sandbox → 避开 seccomp bug
- 不需要 import json / json.loads → 减少代码出错面
- Dify 平台自身负责 JSON parse → 用平台经过验证的功能
常见错误:
- 路径语法写错:
llm_rewrite.json.prizm_blue是正确的,不要写llm_rewrite.prizm_blue - LLM 节点没勾"输出格式=JSON"→ Dify 不会自动 parse,路径语法失败
- LLM 偶尔返回
null字段 → 生图节点拿到空字符串,AI 出图失败;建议在 system prompt 加强约束
5.4 节点 4-8:5 个生图节点(HTTP 请求节点方案)
为什么用 HTTP 请求节点而不是 Dify 图像生成插件:
- Dify 的
langgenius/minimax插件只支持 llm/text-embedding/tts,不支持 image_generation - 即使插件能调 MiniMax LLM,也调不到 MiniMax image_generation 端点(图生图 API)
- 用 HTTP 请求节点 直接 POST
https://api.minimaxi.com/v1/image_generation,传subject_reference参数实现图生图
在 Dify 平台:点"+",搜索 "HTTP 请求" / "HTTP Request",选第一个插件。
通用填写步骤(以节点 4 为例,节点 5-8 改 prompt 字段):
节点 4:HTTP 请求节点 1(Prizm Blue)
- 节点标题:
gen_prizm_blue - 方法:POST
- URL:
https://api.minimaxi.com/v1/image_generation - Headers(JSON):
{
"Authorization": "Bearer {{ env.MINIMAX_API_KEY}}",
"Content-Type": "application/json"
}
⚠️ 在 Dify 控制台"工具 → MiniMax 凭据"页面把 API Key 配置为环境变量
MINIMAX_API_KEY,不要硬编码到节点里。
- Body(JSON):
{
"model": "image-01-live",
"prompt": "{{ llm_rewrite.prizm_blue}}",
"subject_reference": [
{
"type": "character",
"image_file": "{{ start.cutout_url}}"
}
],
"aspect_ratio": "1:1",
"n": "1",
"response_format": "url"
}
关键参数:
subject_reference[].type = "character"+image_file = 抠图 URL,这是 MiniMax 图生图 API 的标准用法。
- 超时:60 秒
- 错误处理:保持默认(Stop on Error)
- 点"保存"
节点 5-8:复制节点 4 修改
每个节点的差异只改 prompt 字段:
| 节点 | 标题 | Body prompt 字段 |
|---|---|---|
| 5 | gen_pokemon_rainbow |
{{ llm_rewrite.pokemon_rainbow}} |
| 6 | gen_topps_refractor |
{{ llm_rewrite.topps_refractor}} |
| 7 | gen_yugioh_secret |
{{ llm_rewrite.yugioh_secret}} |
| 8 | gen_ws_ssp |
{{ llm_rewrite.ws_ssp}} |
Dify 平台操作:
- 节点 4 配置完 → 节点右键 → "复制"
- 改标题 + 改 prompt 字段(共复制 4 次)
响应解析(用于下游汇总节点)
MiniMax API 响应格式:
{
"data": {
"image_urls": ["https://...signed_url...", "..."]
},
"id": "03ff3cd0820949eb8a410056b5f21d38",
"base_resp": {"status_code": 0, "status_msg": "success"}
}
取第 1 张图 URL 的引用(用于下游汇总节点):
{{ gen_prizm_blue.data.image_urls[0] }}
常见错误:
- API Key 写错 → 报 2049 / 1004 状态码(鉴权失败)
subject_reference类型写成"human"而非"character"→ 报 2013 状态码(参数错误)prompt长度 > 1500 字符 → 报 2013 状态码image_fileURL 失效(OSS signed URL 过期)→ AI 看图失败
5.4.1 备选:使用其他支持图生图的模型
如果不用 MiniMax HTTP 请求节点方案,可以用:
| 模型 | 适配 Dify 插件 | 配置方式 |
|---|---|---|
| gpt-image-1(OpenAI) | 装 langgenius/openai 或官方 OpenAI 插件 |
节点用 Dify 图像生成插件直接选 gpt-image-1,prompt 字段 + image 字段(传 cutout_url) |
| Replicate Flux | 装 langgenius/replicate |
配置 Replicate API token,模型选 flux-dev,支持图生图 |
| SDXL + img2img | 装 langgenius/sdxl 插件 |
选 img2img 模型,加 init_image 字段 |
5.4.2 不推荐:保留 MiniMax 纯文生图(无图生图)
如果不想改 Dify 工作流、不想装新插件,可以接受:
- AI 只出"金属底 + 风格氛围"图(纯背景,无人像)
- 用户上传的人像不会被嵌入
- 前端 LenticularCard 显示时用人像覆盖背景图(临时方案)
这种方案用户体验差,不建议生产使用。
5.5 节点 9:代码节点(汇总)
在 Dify 平台:同 §5.3,再加一个 Python 节点。
填写步骤:
- 节点标题:
code_merge - 输入变量:依次添加 5 个生图节点的输出变量(每个节点的图片 URL)
- 代码:
import json
def main(gen1: str, gen2: str, gen3: str, gen4: str, gen5: str) -> dict:
"""
输入: 5 个生图节点的图片 URL
输出: variants 数组(与后端契约对齐)
"""
variants = [
{"preset_id": "prizm_blue", "oss_key": "", "signed_url": gen1},
{"preset_id": "pokemon_rainbow", "oss_key": "", "signed_url": gen2},
{"preset_id": "topps_refractor", "oss_key": "", "signed_url": gen3},
{"preset_id": "yugioh_secret", "oss_key": "", "signed_url": gen4},
{"preset_id": "ws_ssp", "oss_key": "", "signed_url": gen5},
]
return {
"variants": json.dumps(variants, ensure_ascii=False),
"warnings": json.dumps([], ensure_ascii=False),
}
- 点"保存"
5.6 节点 10:结束节点
在 Dify 平台:点"+",选"结束"。
填写步骤:
- 节点标题:
end(默认) - 输出变量:
| 变量名 | 类型 | 引用 |
|---|---|---|
variants |
文本 (string) | {{ code_merge.variants}} |
warnings |
文本 (string) | {{ code_merge.warnings}} |
cutout_url |
文本 (string) | {{ start.cutout_url}} |
- 点"保存"
5.7 节点连线
在 Dify 画布上拖拽连线:
start → llm_rewrite → (5 路分支)
├──→ gen_prizm_blue ─┐
├──→ gen_pokemon_rainbow ─┤
├──→ gen_topps_refractor ─┼→ code_merge → end
├──→ gen_yugioh_secret ─┤
└──→ gen_ws_ssp ─┘
执行模型(重要):
- 5 个生图节点是 DAG 上的独立分支,Dify 调度器并行执行它们(不是顺序)
- 总耗时 ≈ max(5 路) ≈ 10-30 秒(不是累加 50-150 秒)
- 任一节点失败 → 整个工作流立即终止(fail-fast,未配置 Continue)
- 工作流总超时 = 默认 60 秒(任意节点超过即挂)
Dify 平台操作:
- 鼠标悬停在节点边缘,会出现连线点,拖到下一个节点即可
- 5 个生图节点都连到
code_merge的输入
5.8 测试运行
- 点画布右上角"调试"按钮
- 在"用户输入"框直接输入风格描述词(如"梦幻樱花"),Dify 会自动注入为
#context# - 如果用 JSON 输入测试数据:
{
"#context#": "梦幻樱花",
"cutout_url": "https://example.com/test-cutout.png"
}
-
点"开始运行"
-
查看每个节点的输出:
- LLM 节点:应该是 5 个 prompt 的 JSON(耗时 ~3s)
- Code 拆分:应该是 5 个独立变量(耗时 < 1s)
- 5 个生图节点:并行执行,每张图大概需要 10-30 秒,总耗时 = 最慢那一路
- 结束节点:variants 数组里有 5 个 URL
-
如果某节点报错,看节点上的红色叹号,查看错误信息:
- LLM/Code 节点报错 → 工作流立即挂掉,无 variants 输出
- 任一生图节点报错 → 整个工作流立即挂掉(fail-fast),即使其他 4 路成功了也算白跑
- 失败原因记录在工作流日志里,可点击节点查看详细错误
5.9 参数附录(小白填表清单)
本节把 §5.1-5.8 里散落的参数汇总成一张"填表清单",照着填就行。
5.9.1 AI 在哪步填写(关键)
整个工作流里两次填 AI,分别在两个不同节点:
| 节点 | 填什么 AI | 任务 |
|---|---|---|
| 节点 2:LLM 节点 | gpt-4o-mini(文本模型) | 把用户 1 个 prompt 扩写为 5 个英文子 prompt |
| 节点 4-8:5 个生图节点 | gpt-image-1(图像模型) | 每个节点根据 1 个子 prompt 生成 1 张图 |
两次 AI 不能合并:文本模型不会画图,图像模型不会读 prompt 拆解。所以必须 2 类节点。
5.9.2 节点 1 · 开始(Start)
无需参数配置,只设输入变量:
| 参数 | 填什么 | 说明 |
|---|---|---|
| 变量名 | cutout_url |
类型"文本",勾必填 |
user_prompt |
不添加——Dify 原生 #context# 会自动注入用户风格描述词 |
5.9.3 节点 2 · LLM 节点(填文本 AI)
| 参数 | 值 | 说明 |
|---|---|---|
| 模型 (Model) | gpt-4o-mini |
【AI 在这填】 便宜 + 够用;想更稳可改 gpt-4o(贵 30 倍) |
| 温度 (Temperature) | 0.7 |
让 5 个 prompt 有差异化,太低(0.1)会 5 个几乎一样 |
| 最大 Token (Max Tokens) | 2000 |
5 个 prompt 共 ~1500 字符 + buffer |
| Top P | 0.9 |
— |
| 频率惩罚 | 0.0 |
— |
| 存在惩罚 | 0.0 |
— |
| 系统提示 (System Prompt) | [粘贴 §5.2 的整段 prompt] | 关键 |
| 用户提示 (User Prompt) | #context#(Dify 原生特殊变量) |
直接打 #context# 字面量,Dify 平台自动注入用户风格描述词 |
| 输出格式 (Output Format) | JSON |
必选 JSON,否则 Code 节点解析失败 |
5.9.4 节点 3 · 代码节点(拆分 JSON)
| 参数 | 值 | 说明 |
|---|---|---|
| 执行语言 | Python 3 |
不要选 Node.js / Go |
| 输入变量 (Input Variables) | llm_output |
引用 llm_rewrite 节点输出(一般叫 llm_rewrite.text 或 llm_rewrite.output) |
| 代码 (Code) | [粘贴 §5.3 的 Python 代码] | — |
5.9.5 节点 4-8 · 5 个生图节点(填图像 AI)
每个节点结构相同,参数差异只在 Prompt 引用。
节点 4(gen_prizm_blue)配置示例:
| 参数 | 值 | 说明 |
|---|---|---|
| 模型 (Model) | gpt-image-1 |
【AI 在这填】 |
| 图片尺寸 (Size) | 1024 × 1024 |
方形,裁切到 450×600 不变形 |
| 生成数量 (N) | 1 |
每路 1 张 |
| 质量 (Quality) | high |
vivid 比 natural 更适合"彩虹金属"风格 |
| 风格 (Style) | vivid |
— |
| 提示词 (Prompt) | {{ llm_rewrite.prizm_blue}} |
用 / 选变量 |
| 负向提示 (Negative Prompt) | text, people, watermark, logo, signature, flat, matte, 2D, cartoon |
必填,否则 AI 容易出文字/水印 |
5 个节点的差异(只改 Prompt 引用):
| 节点 | 标题 | Prompt 引用 | preset_id |
|---|---|---|---|
| 4 | gen_prizm_blue |
{{ llm_rewrite.prizm_blue}} |
prizm_blue |
| 5 | gen_pokemon_rainbow |
{{ llm_rewrite.pokemon_rainbow}} |
pokemon_rainbow |
| 6 | gen_topps_refractor |
{{ llm_rewrite.topps_refractor}} |
topps_refractor |
| 7 | gen_yugioh_secret |
{{ llm_rewrite.yugioh_secret}} |
yugioh_secret |
| 8 | gen_ws_ssp |
{{ llm_rewrite.ws_ssp}} |
ws_ssp |
Dify 平台操作:节点 4 配置完 → 右键 → 复制 → 改标题和 Prompt 引用(共复制 4 次)
备选图像模型(Dify 平台没有 gpt-image-1 时):
| 模型 | 参数差异 | 备注 |
|---|---|---|
dall-e-3 |
size 改 1024×1024 / 1792×1024 / 1024×1792;无 Style |
OpenAI 经典款;不支持图生图 |
stable-diffusion-xl |
加 negative_prompt 字段;加 guidance_scale: 7.5 |
开源;支持图生图 |
Replicate flux-dev |
加 num_inference_steps: 28;width / height |
开源高质量;支持图生图 |
MiniMax image-01 / image-02 |
模型字段选 image-01;size 支持 1024×1024 / 768×1280;Negative Prompt 不支持(隐含规则);不支持图生图 |
国内主流(你当前用) |
| 腾讯混元 / 阿里通义万相 | 按平台文档填 | 国内替代 |
gpt-image-1 配置(如果你有 OpenAI key,推荐):
模型:gpt-image-1
Prompt:{{ llm_rewrite.prizm_blue}}
图片尺寸:1024x1024
生成数量:1
【关键】image 字段:{{ start.cutout_url}} ← 这个让 AI 把人像融入背景
为什么推荐 gpt-image-1:
- ✓ 同时支持文生图 + 图生图(image field 直接传原图 URL)
- ✓ 质量最高(OpenAI 训练数据最强)
- ✓ 输出包含人像 + 金属底 + 风格氛围(一次成型)
- ✗ 但需要 OpenAI API key(国内访问需要代理)
MiniMax image-01 配置示例(你当前用,纯文生图):
模型:image-01 ← 不是 gpt-image-1
Prompt:{{ llm_rewrite.prizm_blue}}
图片尺寸:1024x1024
生成数量:1
Negative Prompt:(MiniMax 不支持,留空)
【没有 image 字段】← MiniMax 是纯文生图
Dify 插件选择:
- 点"+"搜索"图像生成"或"image generation"
- 选
MiniMax 文生图(langgenius/minimax/minimax-image 插件)—— 不是 langgenius/minimax/minimax(LLM 插件) - 配置 MiniMax API key(在 MiniMax 开放平台 https://api.minimax.chat 申请)
- 模型选
image-01或更新的image-02
与 gpt-image-1 的差异:
| 项 | gpt-image-1 | MiniMax image-01 |
|---|---|---|
| 尺寸选项 | 1024x1024 / 1024x1536 / 1536x1024 / auto | 1024x1024 / 768x1280 |
| Negative Prompt | 支持 | 不支持(隐含规则) |
| 风格 (style) | vivid / natural | 无(不支持) |
| 出图速度 | ~10-30s/张 | ~15-40s/张 |
| 质量 | 高(写实/动漫都好) | 中(偏动漫风格化) |
| 费用 | ~¥0.5-1/张 | ~¥0.1-0.3/张 |
| 图生图(图+文) | ✓ 支持(image field) | ✗ 不支持 |
| 人像融合 | AI 直接处理 | 需后端 compositor 后期叠加 |
注意 1:因为 MiniMax 不支持 Negative Prompt,spec §5.4 里说的"Negative Prompt 字段填 text, people, watermark..." 实际对 MiniMax 无效。需要在 LLM 系统提示的强制约束里强调这些词,LLM 会让生成的 prompt 本身符合"无文字无人"要求。
注意 2:因为 MiniMax 不支持图生图,当前链路生成的图没有人像。如果要"人像 + 镭射背景"融合效果,必须:
- 换 gpt-image-1(推荐)
- 或保留 MiniMax + 用后端 compositor 服务把"AI 出图 + 用户人像"合成(要重启 compositor)
5.9.6 节点 9 · 代码节点(汇总)
| 参数 | 值 |
|---|---|
| 执行语言 | Python 3 |
| 输入变量 (Input Variables) | gen1 / gen2 / gen3 / gen4 / gen5(引用 5 个生图节点输出) |
| 代码 (Code) | [粘贴 §5.5 的 Python 代码] |
5.9.7 节点 10 · 结束(End)
| 输出变量 | 类型 | 引用 |
|---|---|---|
variants |
文本 (string) | {{ code_merge.variants}} |
warnings |
文本 (string) | {{ code_merge.warnings}} |
cutout_url |
文本 (string) | {{ start.cutout_url}} |
5.9.8 总览:你要在 Dify 平台填的所有内容
必填 AI(2 类)
| 节点 | AI 模型 | 必填项 |
|---|---|---|
| 节点 2 | gpt-4o-mini |
模型 + 系统 prompt + 用户 prompt + 输出格式=JSON |
| 节点 4-8(×5) | gpt-image-1 |
模型 + 尺寸 + 数量 + Prompt + 负向 Prompt |
必填变量引用(10 个 → 改为 7 个,因为不再用 user_prompt 自定义变量)
| 节点 | 引用变量 |
|---|---|
| 开始 | cutout_url(只定义 1 个;用户主题由 Dify 原生 #context# 注入) |
| LLM | #context#(在 user prompt 字段直接打 #context#) |
| 代码 split | llm_rewrite.text(或 .output) |
| 生图 1-5 | {{ llm_rewrite.prizm_blue}} ... {{ llm_rewrite.ws_ssp}} |
| 代码 merge | gen1-gen5(5 个生图节点输出) |
| 结束 | {{ code_merge.variants}} / {{ code_merge.warnings}} / {{ start.cutout_url}} |
必填代码(2 段 Python)
- §5.3 JSON 拆分代码(节点 3)
- §5.5 variants 汇总代码(节点 9)
5.9.9 最常见的 3 个坑
- 模型选错:
- LLM 节点选了
gpt-image-1(图像模型不会输出 JSON) - 生图节点选了
gpt-4o(文本模型不会画图)
- LLM 节点选了
- Prompt 引用忘记改:5 个生图节点复制后,Prompt 引用没改 → 5 张图一模一样
- 输出格式忘选 JSON:LLM 返回 markdown
```json ```包起来的字符串,Code 节点解析失败
6. 后端适配
6.1 handleDifyBlocking 改动
文件:backend/gateway/controller/laser_generate_controller.go
现状:L270-383 已实现 Dify 阻塞路径,能解析 variants 数组。
改动:
- 兼容新 preset_id(prizm_blue / pokemon_rainbow / topps_refractor / yugioh_secret / ws_ssp)
attachMaterialsSnapshot中的Role: "composite"逻辑保持不变- 不再依赖
req.RenderConfigs[].GratingConfig(Dify 自己渲,不需要后端 grating 参数)
6.2 前端 useLaserDifyGenerate 改动
文件:frontend/composables/useLaserDifyGenerate.js
改动:
resolveRenderConfigs从返回 4 项改成 5 项- preset_codes 从
['v1','v2','v3','v4']改成['prizm_blue','pokemon_rainbow','topps_refractor','yugioh_secret','ws_ssp'] applySucceeded不再追加 original 为第 5 张(Dify 已经返回 5 张完整的)
6.3 后端 compositor 服务(核心,必须保留)
架构定位:compositor 是最终成品图合成器,AI 出"金属底背景图" → compositor 叠"用户人像 + 镭射光效" → 最终镭射卡。
职责(6 层合成):
| 层 | 内容 | 来源 |
|---|---|---|
| 1 | AI 艺术背景 | Dify 工作流 AI 节点输出 |
| 2 | 金属反光梯度 | 程序生成 |
| 3 | 彩虹衍射光栅色带 | 程序生成(核心镭射效果) |
| 4 | AI 装饰层(可选) | Dify 工作流 AI 节点输出 |
| 5 | 光栅 finish(胶片颗粒 + 磨砂哑光) | 程序生成 |
| 6 | 人物抠图(最顶层) | 用户上传的原图(cutout_url) |
关键改动:
backend/gateway/service/compositor/*.go—— 保留并增强(之前标 DEPRECATED 是错的)backend/gateway/controller/compositor_controller.go—— 保留backend/gateway/router.go:299路由保留
实施(在 handleDifyBlocking 中调用):
// 伪代码(具体实现见后续 PR)
for i, variant := range dVariants {
composited, err := compositor.Compose(compositor.ComposeRequest{
BackgroundURL: variant.SignedURL,
CutoutURL: req.CutoutURL, // 用户上传的人像
GratingConfig: gratingConfigs[i], // 5 套光栅参数
ExportWidth: 450,
ExportHeight: 600,
VariantIndex: i,
})
if err != nil {
// 单张失败 → 整批失败(按 §9.1 fail-fast)
return handleError(c, err)
}
// 上传合成图到 OSS
finalURL := uploadToOSS(composited, variant.OssKey)
dVariants[i].SignedURL = finalURL
dVariants[i].Role = "composite" // 标记是合成图
}
注意:之前 spec 把 compositor 标为 DEPRECATED 是错误的决定——AI 不会完美生成"人像 + 镭射底",必须后端 compositor 合成。
6.4 LASER_GEN_PROVIDER 默认值
文件:backend/gateway/config/config.go:205
改动:
Provider: getEnv("LASER_GEN_PROVIDER", "dify"), // 原值 "minimax"
7. 数据库
7.1 表结构变更
无变更。laser_card_templates / laser_card_instances / laser_card_operation_logs 三张表的 schema 保持不变。
7.2 数据变更(5 条 preset 同步)
把现有的 5 条 laser_card_templates 记录从 dream/classic/holoFull/ice/sunset 更新为 prizm_blue/pokemon_rainbow/topps_refractor/yugioh_secret/ws_ssp:
BEGIN;
-- 删除旧 5 条
UPDATE public.laser_card_templates
SET deleted_at = (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
WHERE template_code IN ('dream', 'classic', 'holoFull', 'ice', 'sunset')
AND deleted_at IS NULL;
-- 同步序列
SELECT setval('laser_card_templates_id_seq', (SELECT MAX(id) FROM public.laser_card_templates));
-- 插入新 5 条
INSERT INTO public.laser_card_templates (
template_code, name, status, version,
backdrop_options, render_config,
engine_min_version, sort_order,
star_id, created_by, created_at, updated_at
) VALUES
('prizm_blue', '蓝棱镜', 'published', 1,
'[{"id":"prizm_blue","label":"蓝棱镜","oss_key":""}]'::jsonb,
'{"style":"prizm_blue","family":"panini_prizm","sheen_band_angle":120,"sheen_intensity":0.38,"sheen_speed":0.42,"foil_coverage":0.70,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 0, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('pokemon_rainbow', '宝可梦彩虹', 'published', 1,
'[{"id":"pokemon_rainbow","label":"宝可梦彩虹","oss_key":""}]'::jsonb,
'{"style":"pokemon_rainbow","family":"pokemon_tcg","sheen_band_angle":100,"sheen_intensity":0.46,"sheen_speed":0.46,"foil_coverage":0.90,"backdrop_tone":"#B0ACA6"}'::jsonb,
'compositor-1.0.0', 1, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('topps_refractor', 'Topps 折射', 'published', 1,
'[{"id":"topps_refractor","label":"Topps 折射","oss_key":""}]'::jsonb,
'{"style":"topps_refractor","family":"topps_chrome","sheen_band_angle":125,"sheen_intensity":0.36,"sheen_speed":0.40,"foil_coverage":0.68,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 2, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('yugioh_secret', '游戏王 Secret', 'published', 1,
'[{"id":"yugioh_secret","label":"游戏王 Secret","oss_key":""}]'::jsonb,
'{"style":"yugioh_secret","family":"yugioh","sheen_band_angle":90,"sheen_intensity":0.44,"sheen_speed":0.44,"foil_coverage":0.90,"backdrop_tone":"#B0ACA6"}'::jsonb,
'compositor-1.0.0', 3, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('ws_ssp', 'WS SSP', 'published', 1,
'[{"id":"ws_ssp","label":"WS SSP","oss_key":""}]'::jsonb,
'{"style":"ws_ssp","family":"weiss","sheen_band_angle":95,"sheen_intensity":0.48,"sheen_speed":0.48,"foil_coverage":0.95,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 4, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint);
-- 同步序列
SELECT setval('laser_card_templates_id_seq', (SELECT MAX(id) FROM public.laser_card_templates));
COMMIT;
7.3 现有 11 条 instance 处理
决策:不动。历史 instance 的 materials_snapshot.preset_id 还是 dream/classic 等旧值,仅作为历史记录保留。新生成的 instance 会用新 preset_id。
8. 前端适配
8.1 useLaserDifyGenerate.resolveRenderConfigs 改动
文件:frontend/composables/useLaserDifyGenerate.js:103-117
新代码:
const PRESET_CODES = [
'prizm_blue',
'pokemon_rainbow',
'topps_refractor',
'yugioh_secret',
'ws_ssp',
]
async function resolveRenderConfigs(_presetIds, userPrompt) {
let prompt = (userPrompt || '').trim().slice(0, 1000)
if (prompt && containsChinese(prompt)) {
console.log('[translate] Chinese detected, translating...')
prompt = await translateToEnglish(prompt)
}
// 5 路并发 AI 生图,每路 1 个卡牌系列(Dify 工作流内部按 preset_id 路由)
return PRESET_CODES.map((code) => ({
preset_id: code,
bg_prompt: prompt || 'Transform this into a premium holographic artwork',
}))
}
8.2 applySucceeded 改动
文件:frontend/composables/useLaserDifyGenerate.js:192-207
新逻辑:
- 不再追加 original 为第 5 张(Dify 已经返回完整 5 张)
- variants 数组直接用 Dify 返回的 5 项
8.3 useLaserBatchGenerate 改动
文件:frontend/composables/useLaserBatchGenerate.js:115
改动:
const genMode = 'dify' // 原值 'openai'
8.4 LenticularCard 接入 WebGL tilt 叠层(关键)
目的:用户在前端查看生成的 5 张镭射卡时,倾斜手机能看到"色彩随角度变化"的效果。
现有资产(全部保留):
| 文件 | 作用 |
|---|---|
frontend/components/lenticular/LenticularCard.vue |
卡片显示容器 |
frontend/composables/useLenticularCraftTiltPreview.js |
tilt 输入 + WebGL 引擎包装 |
frontend/composables/useLenticularPreview.js |
引擎 + 模拟 tilt |
frontend/utils/lenticular-engine.js |
LenticularEngine 核心 |
frontend/utils/webgl/holographic-engine.js |
WebGL 渲染引擎 |
frontend/utils/webgl/holographic-shaders.js |
shader 源码 |
改动点:
LenticularCard.vue已支持imageprop(5 张图作为images数组传入即可)- 新生成的图(5 个 preset_id)需要映射到引擎所需的
backdrop_tone/sheen_band_angle参数:
// 新增映射表(lenticular-engine.js 或独立 config 文件)
const PRESET_PHYSICS = {
prizm_blue: { metal: '#A8ACB2', angle: 120, intensity: 0.38, speed: 0.42, foil: 0.70 },
pokemon_rainbow: { metal: '#B0ACA6', angle: 100, intensity: 0.46, speed: 0.46, foil: 0.90 },
topps_refractor: { metal: '#A8ACB2', angle: 125, intensity: 0.36, speed: 0.40, foil: 0.68 },
yugioh_secret: { metal: '#B0ACA6', angle: 90, intensity: 0.44, speed: 0.44, foil: 0.90 },
ws_ssp: { metal: '#A8ACB2', angle: 95, intensity: 0.48, speed: 0.48, foil: 0.95 },
}
LenticularCard.vue根据当前显示图对应的 preset_id 取物理参数,喂给 engine
详细 WebGL shader 工作原理和 uniform 配置见 §12。
8.5 用户输入规范(前端 UI 引导)
核心问题:用户在"生成镭射卡"页面输入什么?
8.5.1 输入语义
用户输入的应该是**"想要的镭射卡氛围/风格"**,不是 IP 名、人物名、长句子。
8.5.2 输入示例(推荐 1-15 字)
| 类型 | 示例 |
|---|---|
| 季节氛围 | 梦幻樱花 / 夏日海风 / 冬日雪景 / 秋日金黄 |
| 自然现象 | 星空极光 / 银河流星 / 极光之夜 / 晨雾森林 |
| 风格美学 | 赛博朋克 / 蒸汽朋克 / 复古胶片 / 极简主义 |
| 色彩情绪 | 玫瑰金之夜 / 午夜蓝调 / 香槟暖阳 / 翡翠冷光 |
| 节日主题 | 圣诞夜空 / 新年烟花 / 情人节心动 |
8.5.3 完整示例:输入"梦幻樱花"会得到什么
用户输入:梦幻樱花
5 张图都是"梦幻樱花"主题,但工艺不同(用户对比"哪种光效最适合这张图"):
| # | preset_id | 英文 prompt(喂给 gpt-image-1) | 视觉特征 |
|---|---|---|---|
| 1 | prizm_blue | dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, electric blue sapphire accent, sharp reflective sport card mood, centered subject, no text, no people, no watermark |
锐利反射 + 电光蓝 + 樱花元素 |
| 2 | pokemon_rainbow | dreamy cherry blossom, on pearl silver #B0ACA6 metallic card substrate, soft luminous anime glow, premium Japanese TCG mood, centered chibi-style subject, no text, no people, no watermark |
日系动漫 + 闪光粒子 + kawaii 樱花 |
| 3 | topps_refractor | dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, vintage baseball card mood, soft film grain texture, classic portrait composition, no text, no people, no watermark |
复古肖像 + 胶片颗粒 + 樱花 |
| 4 | yugioh_secret | dreamy cherry blossom, on pearl silver #B0ACA6 metallic card substrate, full-card luminous anime art, magic circle elements, dynamic action pose, no text, no people, no watermark |
全卡横纹 + 魔法阵 + 樱花 |
| 5 | ws_ssp | dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, premium anime collectible apex mood, signature-style framed composition, no text, no people, no watermark |
签名框 + 樱花 + 终极收藏感 |
前端展示效果:5 张图横向排列在同一页面,用户能一眼对比"哪个光效最适合"。点选 1 张 → 走铸造流程。
关键设计:5 张图主题完全一致,只是工艺/金属底/光线/构图不同。这才是"5 张差异化"——而不是 5 张完全不同主题的图。
8.5.4 反例(不应该输入)
| 反例 | 为什么不合适 |
|---|---|
| "宝可梦" | 这是 IP 名,不是风格 |
| "我的照片" | 不是风格描述 |
| "生成一张好看的卡" | 太抽象,没具体氛围 |
| "樱花、海浪、星空混合" | 太长(> 15 字),AI 难聚焦 |
| "Pikachu 在草地上玩耍" | 叙事场景,不是风格 |
8.5.5 前端 UI 实现
在 lenticular-create.vue 页面:
<template>
<view class="prompt-input">
<textarea
v-model="userPrompt"
:placeholder="placeholder"
:maxlength="15"
@input="onInput"
/>
<view class="hot-chips">
<view
v-for="chip in hotChips"
:key="chip"
class="chip"
@click="userPrompt = chip"
>
{{ chip }}
</view>
</view>
</view>
</template>
<script setup>
const placeholder = '输入想要的镭射卡风格,如「梦幻樱花」'
const userPrompt = ref('')
const hotChips = ['梦幻樱花', '星空极光', '赛博朋克', '玫瑰金之夜', '复古胶片']
function onInput() {
// 限制 1-15 字
if (userPrompt.value.length > 15) {
userPrompt.value = userPrompt.value.slice(0, 15)
}
}
</script>
关键 UI 元素:
- placeholder 文案:明确告诉用户"输入风格,不是 IP 名"
- 5 个热门 chip:点击即填入,覆盖 80% 场景
- maxlength=15:硬限制,避免超长输入
- 后端兜底:服务端二次校验(见 §8.5.5)
8.5.6 后端校验(兜底)
如果用户绕过前端直接调 API,输入了不合适的内容(如"Pokemon"或长段落),后端 handleDifyBlocking 不做特殊处理——直接转发给 Dify。LLM 会按 system prompt 处理,可能产生不可预期输出但不报错。
不做强校验:因为:
- 限制太死会影响用户体验
- 不可预期输出也是"风格化"的一种表现
- D2 灰度时观察用户实际输入数据再决定是否加严
9. 错误处理
9.1 整体策略:fail-fast(任一节点失败 → 整个工作流失败)
Dify 工作流默认错误传播模型:任何节点报错 → 整个工作流立即中断 → 后端拿到 status: failed。
5 个生图节点不开启 "Continue on Error"——任一生图节点失败就整体失败,前端引导用户重试。
| 场景 | 行为 | 前端展示 |
|---|---|---|
| LLM 节点返回非 JSON | Code 节点抛异常 → 工作流挂掉 | 整体失败,弹"AI 主题扩写失败,请重试" |
| 任一生图节点失败(4 种原因) | 工作流立即挂掉,5 路全废 | 整体失败,弹"AI 生图失败(X/Y 张),请重试" |
| Code merge 节点失败 | 工作流挂掉 | 整体失败,弹"图床合并失败,请重试" |
| 整体超时(> 60s) | Dify 平台 timeout | 整体失败,弹"生成超时,请重试" |
| 后端 OSS 上传失败(理论上不发生) | controller 写 ERROR 日志 | 弹"图床上传失败,请重试" |
9.2 单路失败的 4 种原因
| 原因 | 处理 |
|---|---|
| gpt-image-1 API 限流 | Dify 节点内置重试 1 次;不解决则整体失败 |
| gpt-image-1 内容审核拒绝 | 整体失败,前端引导"换个主题试试" |
| 网络抖动 | Dify 节点内置重试 1 次 |
| AI 出图时长 > 60s | Dify 节点 timeout,整体失败 |
9.3 与前端用户体验的配合
- 整体失败 = 重试按钮(不是"用成功的那几张")
- 重试 = 调
useLaserDifyGenerate.submit()重新走工作流 - 不会"部分成功",用户每次看到 5 张完整图 或 0 张完整图
10. 测试
10.1 单元测试
| 测试 | 工具 |
|---|---|
| useLaserDifyGenerate.resolveRenderConfigs 返回 5 项 | Vitest |
| useLaserDifyGenerate.applySucceeded 不再追加 original | Vitest |
| handleDifyBlocking 解析新 preset_id | Go test |
10.2 集成测试
| 场景 | 步骤 |
|---|---|
| 完整链路 | 上传图 → 输入 prompt → 调 Dify → 收到 5 张 → 选 1 → 铸造 |
| 整体失败(fail-fast 验证) | Mock Dify 让任一生图节点返回 error → 整个工作流失败 → 后端 status=failed → 前端展示"AI 生图失败,请重试" |
| 重试生效 | Mock Dify 让生图节点首次 fail + 重试 succeed → 5 张图正常返回 |
| 主题差异 | 同一图不同 prompt(樱花 vs 海洋 vs 城市)→ 5 张主题不同 |
| 并行执行验证 | 5 路生图节点总耗时 ≈ 最慢一路(不是累加 50s+) |
10.3 灰度
- D1:内部测试账号 100% 流量
- D2:白名单 5% 真实用户
- D3:全量(观察 1 周)
11. 风险与回滚
| 风险 | 缓解 | 回滚 |
|---|---|---|
| Dify 工作流配置错误 | 详细小白指南 + 调试模式 | 把 LASER_GEN_PROVIDER 改回 openai |
| AI 出图质量不稳定 | D2 灰度观察 | 同上 |
| Dify 平台不稳定 | 监控告警 + 备用 OpenAI provider | 同上 |
| 5 个 preset_id 不被前端识别 | 保留兼容逻辑(旧 preset_id 也映射) | 暂时回退到旧 preset |
Dify 代码节点 sandbox 连不上(Name or service not known) |
Dify 部署时确保 sandbox 容器在跑 | 见下方 §11.1 排查步骤 |
11.1 Dify sandbox 服务排查
现象 A:sandbox 容器连不上
报错:Failed to execute code ... Error: [Errno -2] Name or service not known
原因:Dify 代码沙箱(sandbox 容器)连不上或没启动
排查命令:
# 1. 检查 sandbox 容器状态
docker ps | grep sandbox
# 2. 查看日志
docker logs dify-sandbox-1
# 或
docker logs sandbox-1
# 3. 测试网络连通
docker exec dify-api-1 ping sandbox
# 4. 重启 sandbox
docker compose -f docker/docker-compose.yaml restart sandbox
# 5. 如果还是不行,看 docker-compose 是否漏写 sandbox
grep -A5 "sandbox:" docker/docker-compose.yaml
现象 B:Python 启动崩溃(seccomp abort)
报错:
main.DifySeccomp(...ate filter
goroutine 17 [running, locked to thread]:
main.DifySeccomp(...)
/home/runner/work/dify-sandbox/dify-sandbox/cmd/lib/python/main.go:11
error: signal: aborted (core dumped)
根因:docker/dify-deploy.sh 默认下载 Dify v0.15.0,其 sandbox 镜像有 seccomp 限制 bug,启动 Python 子进程时 abort。
修复:升级 Dify 到 v1.x(已修复 seccomp):
完整升级步骤(在 101.132.250.62 上):
# 0. SSH 登录
ssh root@101.132.250.62
cd /opt/dify/docker
# 1. 备份(关键!v1.x 不兼容 v0.15.x workflow)
cp .env .env.bak.$(date +%Y%m%d)
docker exec dify-postgres-1 pg_dump -U postgres dify > /tmp/dify_db_$(date +%Y%m%d).sql
# 2. 停掉旧服务(不要加 -v,会删数据卷)
docker compose down
# 3. 拉新版本 docker-compose
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/docker-compose.yaml \
-o docker-compose.yml
# 4. 拉新 .env.example(保留现有 .env 不动)
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/.env.example \
-o .env.example
# 5. 把 .env.example 新增的变量补到 .env(保留旧值)
diff .env .env.example | head -50
cat .env > .env.new
# 编辑 .env.new:把 .env.example 新增的变量加进去(默认值即可)
mv .env.new .env
# 6. 拉新镜像
docker compose pull
# 7. 启动(首次启动会跑数据库迁移,1-3 分钟)
docker compose up -d
# 8. 看迁移日志
docker compose logs -f api 2>&1 | head -100
# 9. 健康检查
curl -s -o /dev/null -w '%{http_code}' http://localhost:5001/health
# 应该返回 200
# 10. 检查 sandbox 修复
docker ps | grep sandbox
docker logs dify-sandbox-1 --tail 50
# 不应该有 DifySeccomp abort 报错
v1.4.0 新增关键环境变量(如果 .env 缺失需手动补):
# 加到 .env 文件末尾
echo "CONSOLE_API_URL=http://localhost:8084/v1" >> .env
echo "CONSOLE_WEB_URL=http://localhost:8084" >> .env
echo "SERVICE_API_URL=http://localhost:8083/v1" >> .env
echo "APP_API_URL=http://localhost:8083/v1" >> .env
echo "APP_WEB_URL=http://localhost:8083" >> .env
echo "FILES_URL=http://localhost:8083" >> .env
echo "MIGRATION_ENABLED=true" >> .env
echo "SANDBOX_PORT=8194" >> .env
echo "SANDBOX_API_KEY=dify-sandbox" >> .env
升级后必须做:
-
重新导入 workflow(v0.15.x yml 不兼容 v1.x)
- 在 Dify 控制台
http://101.132.250.62:8084手动搭建laser_card_v2_5series工作流 - 或让 Claude 按 §3 / §5 步骤远程协助配置
- 在 Dify 控制台
-
验证 sandbox 修复
- Dify 控制台"调试"模式跑工作流
- 看 5 个生图节点是否各拿到 1 张图
-
测试后端调用
curl -X POST http://localhost:8083/v1/workflows/run \ -H "Authorization: Bearer <API_KEY>" \ -H "Content-Type: application/json" \ -d '{"inputs": {"#context#": "梦幻樱花"}, "user": "test-user"}'
回滚步骤(如果迁移失败):
cd /opt/dify/docker
docker compose down
# 恢复旧版本
cp docker-compose.yml.bak docker-compose.yml
cp .env.bak.20260101 .env
# 启动旧版本
docker compose up -d
# 数据库回滚
docker exec -i dify-postgres-1 psql -U postgres dify < /tmp/dify_db_20260101.sql
现象 C:Dify 平台未部署 / 找不到 sandbox
排查:Dify 可能在另一台服务器部署。
# 1. 确认 Dify 实际部署在哪
grep -E "SERVER_HOST|SERVER_PATH" /Users/liulujian/Documents/code/TopFansByGithub/docker/dify-deploy.sh
# 默认是 SERVER_HOST="101.132.250.62"
# 2. SSH 到 Dify 服务器排查
ssh root@101.132.250.62
docker ps | grep -iE "sandbox|dify"
修复后:在 Dify 工作流"调试"按钮重新跑一次,确认 5 个生图节点各输出 1 张图。
12. 前端展示层(WebGL tilt 实时渲染)
本节为新增加章节。spec §3 / §8 仅覆盖"AI 出图 + 落库",本节专门讲"前端展示时的彩虹随角度变化怎么实现"。
12.1 设计目标
| 维度 | 目标 |
|---|---|
| 倾斜角度 | 用户左/右/上/下倾斜手机(-1..+1 归一化) |
| 色彩响应 | 彩虹色带方向、高光位置、强度随 viewAngle 偏移 |
| 性能 | 60fps(移动端 GPU 需 < 5ms / 帧) |
| 兼容 | iOS Safari / Android Chrome / 微信内置 / H5 |
12.2 技术栈(已存在 + 复用)
| 层 | 文件 | 角色 |
|---|---|---|
| 输入 | useLenticularCraftTiltPreview.js |
陀螺仪 / 触摸 → 归一化 tilt 输入 |
| 引擎 | utils/lenticular-engine.js |
LenticularEngine 类,物理参数 + sensor 输入 |
| 渲染 | utils/webgl/holographic-engine.js |
WebGL 上下文 + uniform 写入 |
| Shader | utils/webgl/holographic-shaders.js |
GLSL 源码(含 rainbow 计算) |
| 容器 | components/lenticular/LenticularCard.vue |
Vue 组件,绑定 canvas |
所有层已存在,本次只做参数接线,不重写引擎。
12.3 Shader 关键 uniform
文件:frontend/utils/webgl/holographic-shaders.js
| uniform | 类型 | 来源 | 作用 |
|---|---|---|---|
u_viewAngle |
vec3 | tilt (dx, dy, dz) | 视角方向 → 决定色带偏移 |
u_lightDir |
vec3 | 固定 (0, 0, 1) | 光源方向(屏幕外) |
u_dispersionStrength |
float | preset.foil_coverage | 色散强度(彩虹宽度) |
u_highlightSpeed |
float | preset.sheen_speed | 高光移动速度 |
u_highlightWidth |
float | preset.sheen_intensity | 高光带宽度 |
u_baseImage |
sampler2D | AI 出的静态图 | 底图 |
u_spectrumRamp |
sampler2D | 预生成彩虹 LUT | 彩虹色查找表 |
关键公式(fragment shader 内):
// 把 viewAngle 投影到屏幕空间 → 计算色带偏移量
vec2 offsetDir = normalize(u_viewAngle.xy);
float offsetAmt = length(u_viewAngle.xy) * u_dispersionStrength;
// 在 uvs 上加偏移 → 采样 spectrum ramp
vec2 rampUV = v_uv + offsetDir * offsetAmt;
vec3 rainbow = texture(u_spectrumRamp, rampUV).rgb;
// 高光带:1D 投影
float highlightPos = dot(v_uv, offsetDir);
float highlight = smoothstep(u_highlightWidth, 0.0, abs(highlightPos - 0.5));
rainbow += vec3(highlight) * u_highlightSpeed;
// 屏幕混合模式
vec3 finalColor = baseColor + rainbow * 0.5;
gl_FragColor = vec4(finalColor, 1.0);
12.4 5 个 preset 的物理参数映射
| preset_id | metal hex | sheen angle | intensity | speed | foil |
|---|---|---|---|---|---|
prizm_blue |
#A8ACB2 |
120° | 0.38 | 0.42 | 0.70 |
pokemon_rainbow |
#B0ACA6 |
100° | 0.46 | 0.46 | 0.90 |
topps_refractor |
#A8ACB2 |
125° | 0.36 | 0.40 | 0.68 |
yugioh_secret |
#B0ACA6 |
90° | 0.44 | 0.44 | 0.90 |
ws_ssp |
#A8ACB2 |
95° | 0.48 | 0.48 | 0.95 |
映射表存放位置:frontend/utils/lenticular-engine.js 内新增 PRESET_PHYSICS_MAP 常量。
12.5 tilt 输入流
[陀螺仪/触摸]
↓
DeviceOrientationEvent / touchstart/touchmove
↓
useLenticularCraftTiltPreview.simulateTilt(dx, dy)
↓ (归一化 -1..+1)
LenticularEngine.feedSensor({x, y, z})
↓ (物理平滑 + 滤波)
LenticularEngine.computeRenderState()
↓
{ viewAngle: vec3, displacement: vec2 }
↓
holographic-engine.setUniforms({ u_viewAngle, ... })
↓
WebGL draw call → 60fps 输出
12.6 与既有 LenticularCard 集成
当前状态:LenticularCard.vue 已支持 tilt 渲染,但绑定的物理参数是写死的。
改动:让 LenticularCard.vue 接受 presetId prop,根据 presetId 从 PRESET_PHYSICS_MAP 取物理参数,喂给 LenticularEngine。
<!-- LenticularCard.vue -->
<script setup>
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview'
import { PRESET_PHYSICS_MAP } from '@/utils/lenticular-engine'
const props = defineProps({
image: { type: String, required: true },
presetId: { type: String, default: 'prizm_blue' },
})
const physics = PRESET_PHYSICS_MAP[props.presetId] || PRESET_PHYSICS_MAP.prizm_blue
const preview = useLenticularCraftTiltPreview({
canvasId: props.canvasId,
baseImage: props.image,
physics, // ← 新增 prop
})
</script>
12.7 失败兜底
| 场景 | 兜底 |
|---|---|
| WebGL 上下文创建失败(老旧设备) | 降级到 <img> 静态展示 |
| 陀螺仪 API 不可用(H5 / 微信内置) | 触摸滑块控制 tilt |
| shader 编译失败 | 控制台日志 + 静态展示 |
12.8 性能预算
- 单帧时间:< 5ms(移动端中端机型)
- shader 复杂度:限制在 20 条 ALU 指令以内
- 纹理数量:≤ 3(baseImage + spectrumRamp + 可选 scratchMap)
13. 待用户审 spec 的问题
在开始 writing-plans 前,需要你确认:
- 设计方向:路径 C(Dify 工作流)+ 5 系列 × 风格描述词 + AI 出金属底卡 + 前端 WebGL tilt 实时叠彩虹,是否符合预期?
- preset_id 命名:prizm_blue / pokemon_rainbow / topps_refractor / yugioh_secret / ws_ssp,可以接受吗?
- 数据库 5 条模板:用 §7.2 的 SQL 直接覆盖现有的 dream/classic/holoFull/ice/sunset,可以接受吗?
- 后端 compositor:本次只标记 DEPRECATED(保留供调试),删除放在后续 PR。可以接受吗?
- 灰度策略:先 100% 内部 → 5% 白名单 → 全量。可以接受吗?
- Dify 平台 + 模型:
- 你的 Dify 平台是否已部署?是否支持 gpt-image-1?(如不支持,告诉我备选)
- LLM 节点用
gpt-4o-mini还是minimax-m3(你实际在用的)?
- 前端 WebGL tilt:是否接受 §12 的方案——LenticularEngine 复用 + PRESET_PHYSICS_MAP 映射 + LenticularCard 改造?
- AI prompt 调整:是否接受 §5.2 新的 LLM prompt——强调金属底、严禁 holographic/rainbow 等词?
- 用户输入规范:是否接受 §8.5 的方案——前端输入框 placeholder + 5 个热门风格 chip + maxlength=15?