Files
codex-market-app/CANVAS_SPEC.md
T

153 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 创意画布(Canvas Studio)集成契约 —— 冻结版
主控已勘定的集成事实,技术员必须严格遵守。目标:在插件市场旁新增「创意画布」,
Drawflow 卡片式节点画布,第一版跑通 **文生图 → 图生视频 → TTS配音** 链路,
全部走 tokendun 中转,**key 只在主进程**。
## 已知事实(不要重复调研)
- Electron 应用,**纯原生 JS,无 React/Vue,无构建步骤**:`renderer/index.html` 直接 `<script src="app.js">`。
- CSP(index.html 第 7 行):`default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;`
- 远程 http 图片/视频 URL 在渲染层**会被拦截** → 所有生成结果必须由**主进程下载并转 base64 data URL** 返回。
- 渲染层桥接对象:`window.api`(app.js 里别名 `apic`)。所有后端调用走 IPC。
- Tab 机制:`activatePanel(name)` 显示 `#tab-${name}`,激活 `.tab[data-tab=name]`。
- 中转分组已由 `electron/lib/router.js` 管理并**已导出** `llmGroup()`:
返回当前可用中转分组 `{ id, name, baseUrl, key, model, ... }`(无可用则 `null`)。
执行层**直接复用它**,不要新增中转配置。
- 设计变量在 `renderer/styles.css :root`:`--grad --accent --bg --card --card-2 --border --text --muted --ok --danger --radius --shadow`,含明/暗色。节点样式必须复用这些变量。
## 冻结的 Preload 契约(前后端都按这个来)
后端在 `electron/preload.js` 的 `window.api` 中新增:
```js
canvasStatus: () => ipcRenderer.invoke("canvas-status"),
// → { ready: boolean, provider: { name, model, host } | null, reason?: string }
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
// kind ∈ "image" | "video" | "tts"
// → { ok: true, mime, dataUrl, meta } | { ok: false, error }
```
### canvasRun 各 kind 的 params 与端点(OpenAI 兼容)
- `image`:`{ prompt, size?, model? }`
→ `POST {base}/v1/images/generations`,body `{ model, prompt, size, response_format:"b64_json" }`
→ 兼容 `data[0].b64_json` 或 `data[0].url`(url 则主进程再 fetch 下载)。返回 `image/png` data URL。
- `tts`:`{ text, voice?, model?, format? }`
→ `POST {base}/v1/audio/speech`,body `{ model, input:text, voice:voice||"alloy", response_format:format||"mp3" }`
→ 响应是二进制音频 → 转 `audio/mpeg` data URL。
- `video`:`{ prompt, image?, model?, endpointPath?, extra? }`
→ 视频接口各中转不统一。默认尝试 `POST {base}{endpointPath||"/v1/videos"}`,
body `{ model, prompt, image, ...extra }`;若返回任务 id 则轮询(best-effort)。
**拿不到就返回 `{ ok:false, error }` 并说明「该中转视频接口待适配」,不要伪造成功。**
此节点标注为「实验/可配置」。
`meta` 建议含 `{ model, ms, host }` 便于前端展示。
## 【v2 增补 · 完善配置能力】——本轮必须实现
用户反馈:图像/视频**不能只用聊天模型**(会 404),必须能自填「模型 + 接口地址」,且尺寸/比例/视频模式要齐全。
### canvasRun 参数扩展(后端)
- **image** `{ prompt, model, size, endpointPath?, extra?, image?, refField? }`
- `model`:**用户填写的图像模型**(如 `gpt-image-1`/`dall-e-3`/`flux-pro`/`seedream-3` 等)。**为空则直接返回 `{ok:false,error:"请先填写图像模型"}`,不要回退聊天模型。**
- `size`:`"WxH"`(前端按 比例×清晰度 算好传来)。
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance 2.0**,使用新中转契约。
- 创建端点默认 `/v1/video/generations`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
- 请求体是扁平结构:`prompt` 必填,固定 `async:true`;`duration` 仅发送整数 `5/10/15`;`ratio`、`quality` 非自动时才发送;`model` 可省略并使用上游默认。
- `mode`:`"text"` 只发送文本 | `"first"` 发送 `image` | `"firstlast"` 将首尾两图作为 `images` | `"reference"` 按 `referenceField` 发送 `images` 或 `ref_assets`。旧值 `"image"` 归一为 `"first"`。
- 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。
- 不发送旧版 `input_mode`、`metadata`、`resolution`、`generate_audio`、`watermark`、`camera_fixed`、`seed`、`reference_video` 字段。
- 创建响应任务 ID 兼容 `request_id/requestId/task_id/taskId/id`,并递归检查 `data/result/task`;字符串和数字 ID 都支持。
- **grokVideo** `{ mode, prompt, model, image?, referenceImages?[], resolution?, seconds?, aspectRatio?, endpointPath? }` —— **Grok 视频**。首帧图生视频默认走 `model:"grok-video-1.5"`,提交体为 `{ model, prompt, seconds:"6", size:"1280x720", image:{url:"https://.../image.png"} }`;上传/上游传来的 dataURL 会先转公网 URL,再放入 `image.url`。`resolution + aspectRatio` 只在本地映射为 `size`(如 `9:16 + 720p -> "720x1280"`),不下发 `duration/resolution/image_url/aspect_ratio`。`mode:"reference"` 走旧 Reference-to-Video:强制 `model:"grok-imagine-video"`、`resolution:"720p"`,把 `referenceImages[]` 解析为 `reference_images: [{url}]`,并下发 `aspect_ratio`。
- **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。
### 新增 canvasModels(后端 + preload)
- preload:`canvasModels: () => ipcRenderer.invoke("canvas-models")`
- 主进程:调 `router.fetchModels(llm.baseUrl, llm.key)` 返回 `{ ok, models:[...] }`(无可用分组或失败返回 `{ok:false,error}`)。供前端填充「模型」下拉/datalist。
### 前端节点表单要求(每个能力节点都要能配置)
- **文生图节点**:提示词 + **模型输入框(带 datalist,可点“拉取模型”从 canvasModels 填充)** + **比例下拉**(1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2) + **清晰度下拉**(1K / 2K / 4K)(前端据此算出 `WxH`,长边:1K=1024,2K=2048,4K=4096,短边=长边×比例取整)+ 折叠的「高级」里放**接口地址**覆盖 + 运行 + 预览/报错。
- **视频节点**:**模式下拉(图生视频/文生视频)** + 提示词 + 模型输入框(datalist+拉取) + 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p) + 时长(可选 4s/6s/8s) + 高级(接口地址) + 运行 + 预览/报错。图生视频模式吃上游图,文生视频不需要。
- **TTS 节点**:文本 + 模型输入框 + 音色 + 格式(mp3/wav/opus) + 运行 + 预览。
- 「拉取模型」按钮:调 `apic.canvasModels()`,把返回的 models 灌进所有模型输入框的 `<datalist>`;失败给 toast。
- 预置示例(cvSeedSample)里,模型字段留空并加占位提示(如「填入你中转的图像模型,如 flux-pro」),避免又用聊天模型 404。
所有 base 用 `router.llmGroup()` 的 `baseUrl`(去尾斜杠),鉴权 `Authorization: Bearer <key>`,
`User-Agent: "codex-market"`(与 router.fetchModels 一致)。用 Electron 内置全局 `fetch`。
## 文件归属(不得越界,避免冲突)
- 后端技术员:`electron/lib/canvas.js`(新建)、`electron/main.js`(加 require + 两个 ipc handler)、`electron/preload.js`(加两个方法)。
- 前端技术员:`renderer/index.html`、`renderer/app.js`、`renderer/styles.css`、`renderer/vendor/`(新建,放 drawflow)。
仅前端可改 index.html 的 CSP:新增 `media-src 'self' data: blob:;`(供音视频 data URL 播放),其余不动。
## 第一版节点集
1. 文本/提示词(输入源)
2. 文生图(image)——出图,输出可连到下游
3. 图生视频(video,实验)——吃上游图 + 提示词
4. TTS 配音(tts)——吃文本,出音频
每个节点自带「运行」按钮、内联结果预览、loading/错误态。节点间用 Drawflow 连线传递输出。
无可用中转分组时(canvasStatus.ready=false):画布置灰 + 提示「请先在『路由中转』配置并激活一个中转分组」。
## 【v3 增补 · 按节点选择中转分组】——本轮必须实现
用户希望:在「路由中转」建好多个分组(如"画图中转""视频中转"),打开画布时**自动加载全部分组**,每个能力节点用一个「分组」下拉自选走哪条中转渠道。设计:**分组=选哪条中转渠道(baseUrl+key),模型框=选该渠道上的具体模型。**
### router.js(主控已加,勿动)
已新增只读接口:`groupById(id)`(返回含解密 key 的分组或 null)、`usableGroups()`(非默认且配齐 baseUrl+key 的分组数组)。
### 后端(canvas.js / main.js / preload.js)
- 新增 `groups()`:基于 `router.usableGroups()` 返回 `{ ok:true, activeId, groups:[{id,name,model,host}] }`(host 由 baseUrl 解析;activeId 来自 router.status().activeId 或 llmGroup 的 id)。无可用分组 `{ok:false,error}`。
- `run({kind,params})`:若 `params.groupId` 非空 → 用 `router.groupById(groupId)` 解析渠道(baseUrl+key);否则回退 `router.llmGroup()`。解析到的分组缺 baseUrl/key → 返回明确错误。其余逻辑(模型必填、端点、body 组装)不变。
- `listModels(groupId)`:接受可选 groupId,有则用该分组的 baseUrl+key,否则 llmGroup。
- main.js:`ipcMain.handle("canvas-groups", () => canvas.groups());`;把 canvas-models 改为 `(_e,{groupId}) => canvas.listModels(groupId)`。
- preload.js:`canvasGroups: () => ipcRenderer.invoke("canvas-groups")`;`canvasModels: (groupId) => ipcRenderer.invoke("canvas-models",{groupId})`。
### 前端(renderer/*)
- 进入画布时调 `apic.canvasGroups()`,缓存分组列表。给**文生图/视频/TTS 每个节点**加一个「分组」下拉 `.cv-field-group`(option value=分组id,文案=`名称 · host`),默认选中 activeId。
- 节点运行时 params 带上 `groupId=选中的分组id`。
- 「拉取模型」改为**每节点一个「拉取」小按钮**(放模型框旁),调 `apic.canvasModels(该节点选中的groupId)`,填充该节点自己的 `<datalist id="cv-models-{nodeId}">`;模型框 `list` 指向它。(工具条原全局「拉取模型」可保留为拉取默认分组填充所有,或移除,二选一,别报错即可。)
- 选择分组后若模型框为空,可用该分组的 model 预填(来自 canvasGroups 数据)。
- 新增节点(cvAddNode)与预置示例(cvSeedSample)都要带分组下拉并默认选中 activeId;画布首次进入若分组数据未就绪,加载后要回填已存在节点的下拉。
- 无可用分组时:节点内提示「请先在『路由中转』添加中转分组」。
## 【v4 增补 · 流式图片生成 + 渐进预览】——本轮必须实现
用户要求图片流式生成(gpt-image 支持 partial image 流式;且 gpt-image 不接受 response_format)。
### 后端(canvas.js / main.js / preload.js)
- **image 改为 stream-first**:POST `{base}{endpointPath||"/v1/images/generations"}`,body `{ model, prompt, size, n:1, stream:true, partial_images:2, ...extra }`。**不要再发 `response_format`**(gpt-image 会报错;dall-e 系无 stream 时走回退分支即可)。
- 用 Electron 全局 fetch,读 `res.body`(web ReadableStream,`for await (const chunk of res.body)` 可迭代);`TextDecoder` 累积、按行切、解析 `data: {...}`(跳过 `data: [DONE]`)。
- 事件里凡带 `b64_json` 的都取出:`type` 含 `partial` → 渐进帧,回调 `onPartial(dataUrl)`;`type` 含 `completed` 或流结束时的最后一帧 → 最终图。返回最终 `{ok:true, mime:"image/png", dataUrl, meta}`。
- **回退**:若响应不是 SSE(Content-Type 非 text/event-stream 或首包不是 data:),或 stream 请求返回非 2xx → 退回原「非流式」POST(body 去掉 stream/partial_images,gpt-image 不加 response_format,其它模型可加 b64_json)解析 `data[0].b64_json|url`。错误信息仍带 URL+model。
- **run 签名**:`run({ kind, params }, onPartial)` — 多接一个可选回调;仅 image 流式用到。
- **main.js**:`canvas-run` handler 改为
`ipcMain.handle("canvas-run", (e, { kind, params }) => canvas.run({ kind, params }, (dataUrl) => { try { e.sender.send("canvas-progress", { runId: params && params.runId, kind, dataUrl }); } catch {} }));`
- **preload.js**:新增
`onCanvasProgress: (cb) => { const h = (_e, p) => cb(p); ipcRenderer.on("canvas-progress", h); return () => ipcRenderer.removeListener("canvas-progress", h); },`
### 前端(renderer/*)
- 运行前生成 `runId`(如 `nodeId + "-" + (++CANVAS.runSeq)`,Date.now 亦可),塞进 `params.runId`,并把 `nodeId` 与 `runId` 映射记下。
- 启动时订阅一次 `apic.onCanvasProgress(({runId, dataUrl}) => 用映射找到对应节点预览,把 <img> 的 src 换成 dataUrl)`,实现**边生成边刷新预览**(partial 帧陆续替换)。
- 最终 `canvasRun` resolve 的成品仍照常渲染覆盖;skeleton 在收到第一帧 partial 后即可替换为图片。
- 仅 image 节点用到渐进预览;video/tts 不变。
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】
Seedance 使用异步任务制:建任务→轮询→下载。以下内容按当前 `/v1/video/generations` 文档更新,覆盖早期火山 `metadata/input_mode` 版本。
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
- 端点:create = `joinUrl(base, endpointPath||"/v1/video/generations")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
- **body 组装**(严格照 Seedance):
- 基础字段 `{ prompt, async:true }`;`model` 可选。
- `params.seconds` 仅为 `5/10/15` 时转成整数 `duration`;`ratio`、`quality` 非 `auto` 时按顶层字段发送。
- 文生视频不发送图片字段;首帧发送 `image:string`;多图发送 `images:string[]`;全能参考发送 `ref_assets:string[]`。
- 图片必须是公网 URL;本地 data URL 先经 `uploadMedia()` 转成公网地址。
- **建任务**:POST create。2xx 才继续;非 2xx 返回状态、实际 URL、model 和上游详情。响应先读取 text 再解析 JSON,确保非 JSON 错误不会丢失原文。任务 ID 由通用 `pickTaskId()` 提取。
- **轮询**:GET poll,每 5s 一次,最多约 10 分钟(120 次)。用 `normalizeVideoStatus`;succeeded → `findVideoUrl`(深挖 `video_url/result_url/url`)→ 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。
- port 两个纯函数 `normalizeVideoStatus`、`findVideoUrl` 到 canvas.js。全程 try/catch。
### 前端视频节点(renderer/*)
- 模式:首帧图、双图参考、多参考图/全能参考、文生视频。
- `prompt` 在所有模式必填;模型可留空使用上游默认,也可通过分组拉取当前可用模型。
- 比例:16:9 / 9:16 / 1:1 / 4:3;画质:自动 / `hd`;时长:自动 / 5s / 10s / 15s。
- 高级:接口地址(默认 `/v1/video/generations`)和多参考图字段(`images/ref_assets`)。
- 不展示旧接口已不支持的分辨率、音频、水印、固定镜头、seed、参考视频控件。
- 运行 params:`{ mode, prompt, model?, ratio, quality, seconds, referenceField, image?, lastFrameImage?, referenceImages?, endpointPath?, groupId }`。