Files

16 KiB
Raw Permalink Blame History

创意画布(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 中新增:

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 / Seedance V2。
    • 画布分组下拉会识别并优先展示 Seedance V2(名称含 Seedance V2 / seedance_v2),与旧 Seedance/CLMM 分组并存。
    • Seedance V2 走 Binghuo 原生:创建端点默认 /v1/video/generations,body 使用 model/prompt/duration/ratio/resolution/images|start_frame|end_frame。
    • 旧 Seedance / CLMM 继续走聚合契约:创建端点默认 /v1/videos;若分组 baseUrl 已以 /v1 结尾,拼接时不得产生重复的 /v1/v1。
    • 请求体遵循 CLMM 文档:model、prompt、aspect_ratio、resolution、size、seconds、mySeconds、reference_image_urls。模型包含 -Ns 时使用 seconds:"1";包含 -gz 时将 mySeconds 固定为模型声明的 N 秒。
    • mode:"text" 只发送空参考图数组 | "first" 发送一张 reference_image_urls | "firstlast" 发送首尾两张 | "reference" 发送多张参考图。旧值 "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 视频。与 Seedance 节点一样通过 CLMM 提交,使用 { model, prompt, aspect_ratio, resolution, size, seconds, mySeconds?, reference_image_urls };上传/上游传来的 dataURL 会先转公网 URL。模型名必须使用模型列表返回的完整名称,不再发送 Grok 专用的 image:{url} 或 reference_images:[{url}] 字段。
  • 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/2K/4K) + 时长(可选 4s/6s/8s) + 高级(接口地址) + 运行 + 预览/报错。2K/4K 仅透传给中转,是否可用取决于所选 Seedance 模型;官方常规模型通常最高 1080p。图生视频模式吃上游图,文生视频不需要。
  • 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 和 Grok 节点都使用 CLMM 异步任务制:建任务→轮询→下载。以下内容按 /v1/videos 文档更新,覆盖早期 Seedance/Grok 专有参数版本。

后端 runVideo 重写(canvas.js)——统一使用 CLMM /v1/videos 逻辑

  • 端点:create = joinUrl(base, endpointPath||"/v1/videos");poll = create + "/" + taskId(GET)。鉴权 Bearer <key>(分组由 groupId 解析,同 v3)。
  • body 组装(严格照 CLMM):
    • 基础字段 { model, prompt, aspect_ratio, resolution, size, seconds, reference_image_urls:[] }。
    • 模型包含 -Ns 时发送 seconds:"1" 和 mySeconds;包含 -gz 时 mySeconds 固定为模型声明的 N 秒。
    • 文生视频发送空的 reference_image_urls;首帧、首尾帧、多参考图都统一发送公网 URL 数组。
    • 图片必须是公网 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/videos)。多参考图统一使用 CLMM 字段 reference_image_urls。
  • 不展示旧接口已不支持的分辨率、音频、水印、固定镜头、seed、参考视频控件。
  • 运行 params:{ mode, prompt, model?, ratio, quality, seconds, referenceField, image?, lastFrameImage?, referenceImages?, endpointPath?, groupId }。