2026-07-15 18:01:56 +08:00
|
|
|
|
# 创意画布(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`,允许用户覆盖。
|
2026-07-20 08:48:56 +08:00
|
|
|
|
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
|
2026-07-20 08:48:56 +08:00
|
|
|
|
- **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`。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
- **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 不变。
|
|
|
|
|
|
|
2026-07-20 08:48:56 +08:00
|
|
|
|
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】
|
|
|
|
|
|
Seedance 使用异步任务制:建任务→轮询→下载。以下内容按当前 `/v1/video/generations` 文档更新,覆盖早期火山 `metadata/input_mode` 版本。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
|
|
|
|
|
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
|
2026-07-20 08:48:56 +08:00
|
|
|
|
- 端点:create = `joinUrl(base, endpointPath||"/v1/video/generations")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
- **body 组装**(严格照 Seedance):
|
2026-07-20 08:48:56 +08:00
|
|
|
|
- 基础字段 `{ 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/超时 → 明确错误。仍严禁伪造成功。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
- port 两个纯函数 `normalizeVideoStatus`、`findVideoUrl` 到 canvas.js。全程 try/catch。
|
|
|
|
|
|
|
|
|
|
|
|
### 前端视频节点(renderer/*)
|
2026-07-20 08:48:56 +08:00
|
|
|
|
- 模式:首帧图、双图参考、多参考图/全能参考、文生视频。
|
|
|
|
|
|
- `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 }`。
|