“我受够了来回切工具于是让 ds-v4.1-flash 从零写了一个基于无限画布思路的 AI 创作台把生图、改图、生视频全部塞进同一张画布用节点连线的方式串成一条流水线。这篇就把整个项目的架构设计、关键代码、坑位和最终效果全摊开聊。”1. 项目动机被“碎片化创作流程”逼出来的自研工具1.1 一次真实创作流程的“时间账”我先描述一个场景你想做一段十秒的创意短视频主题是“赛博城市夜晚天台上的人物特写”。按正常流程你得先在文生图平台把首帧画面生成出来然后再打开修图工具去做局部调整比如换脸、改光线、去瑕疵最后把成品图上传到视频生成平台输入运动描述等待视频输出。这一套流程走下来真正花在“创作”上的时间可能只有两成剩下八成全耗在反复搬运文件、重写 prompt、适应不同平台的参数规则上。文生图那里写的 prompt 是“赛博城市、雨夜、霓虹、人物特写”到了视频生成平台这套描述可能不够用你得把它拆成“画面主体”和“镜头运动”两部分重新组织语言。图片也要先下载到本地再上传到下一个平台中间还可能被压缩画质。这种工具割裂的问题本质上不是模型能力不够而是工作流设计的问题。各平台的能力都在线但连接人的环节几乎为零。我当时就想能不能把这三类能力收拢到一个界面里让图像数据在内部直接流动prompt 也根据节点类型自动适配这个想法最终越滚越大变成了一个完整的“无限画布 AI 创作台”项目。1.2 为什么是无限画布而不是传统流程编排市面上其实已经有流程编排类工具做得比较典型的是以对话或知识库为核心的那类流水线平台比如 dify 这类工具就很适合做 RAG 流程、文本处理流。但这类工具面对图像创作有一个致命短板你没法“看着图”去调参数也没法直观地看到某一版图是从哪一步演化来的。图像创作天然是视觉导向的我需要的是“画布即工作台”这种交互形式。无限画布能无限缩放和平移节点就是一次 AI 操作连线就是数据依赖关系。缩小看全局能一眼看清从“文本”到“图片”再到“视频”的整条链路放大看细节能看到每个节点的参数配置和产物预览。改完某个节点的 prompt下游节点的输入自动变这种体验在传统列表式编排工具里很难实现。还有一个很现实的原因画布上手成本极低。用过 Figma、Miro、Excalidraw 的人都能秒懂这套交互不需要重新教用户“什么是流程节点”。我自己试过用表格加按钮的方式来组织流水线结果做出来的东西更像后台管理系统完全没有创作的氛围感。1.3 这次为什么要让大模型“从零写”项目确定要做后第一个问题就是谁写代码我本身能写但这个项目涉及无限画布渲染、节点系统、多模态模型 API 接入、异步任务调度工作量不算小。更关键的是我想验证一个问题现在的 AI 辅助编程到底能不能支撑一个中等复杂度的多模态前端项目于是定下了这次的目标从空目录开始全程以 ds-v4.1-flash 作为主力编程伙伴我负责定架构和数据契约它负责填充实现遇到 bug 就一起排查迭代。项目启动前我设了几个验收标准画布能流畅平移缩放节点可拖拽、可连线文生图节点输入 prompt 和参数输出图片产物改图节点支持局部重绘和面部融合类图生图操作视频节点接收上游图片生成短时视频片段以上四个能力能串成一条链一键跑通并自动保存中间产物说实话一开始我并没有把握全部跑通。特别是生视频这种异步长任务要在画布上做状态反馈、进度轮询、失败重试逻辑链路不短。但既然要验证“大模型从零写项目”这件事就干脆把目标定完整一点跑不通再降级。2. 无限画布的底层实现与“白屏”那几天2.1 视口变换无限画布的本质只是一个坐标映射很多人听到“无限画布”觉得很高端但拆到最底层它就是一个坐标系变换的问题。你的节点数据存储在世界坐标系里画布只是通过一个摄像机参数camera把世界坐标映射到屏幕坐标。这里的三要素是 camera.x、camera.y 和 camera.zoom。// camera 表示视口中心在世界坐标系中的位置zoom 表示缩放倍率 interface Camera { x: number; y: number; zoom: number; } function worldToScreen(world: { x: number; y: number }, camera: Camera, viewport: { width: number; height: number }) { return { x: (world.x - camera.x) * camera.zoom viewport.width / 2, y: (world.y - camera.y) * camera.zoom viewport.height / 2, }; } function screenToWorld(screen: { x: number; y: number }, camera: Camera, viewport: { width: number; height: number }) { return { x: (screen.x - viewport.width / 2) / camera.zoom camera.x, y: (screen.y - viewport.height / 2) / camera.zoom camera.y, }; }我把这段基础代码让 ds-v4.1-flash 生成它第一版给出来的函数漏了 viewport 中心偏移导致拖动画布时所有节点都以画布左上角为基准跳动。这个 bug 后面会单独讲排查过程。实际上只要把这两个函数写对无限画布的骨架就算立住了鼠标拖拽改变 camera.x 和 camera.y滚轮改变 zoom再配合最小缩放和最大缩放的钳制就能得到一个很顺滑的无限画布。2.2 节点、端口与连线数据流的可视化句法节点系统是整个创作台的语义核心。每个节点就是一个 AI 操作单元节点之间通过连线形成数据依赖。这里的数据结构设计直接决定后面所有功能的扩展空间。type NodeType textToImage | imageToImage | imageToVideo; interface FlowNode { id: string; type: NodeType; x: number; y: number; inputs: Recordstring, any; // 节点配置参数与上游数据 outputs: Recordstring, any; // 节点产物图片或视频的文件引用路径 status: idle | running | done | error; } interface Connection { id: string; from: { nodeId: string; port: string }; to: { nodeId: string; port: string }; }节点只关注自己的 input 和 output 契约连线让上游的输出自动成为下游的输入。这有点像电路板上的元件和走线每个元件完成一次信号处理焊接好的线路决定信号流向。你不需要把整条流水线写死在代码里而是通过画布上的可视连线动态组合。在设计这个交互时我特意加了“端口”的概念而不是节点整包连线。比如一个改图节点有“原始图”端口和“蒙版图”端口上游可以分别连接不同的输出这样灵活性比单输入单输出强很多。ds-v4.1-flash 在实现“节点拖动时连线跟随”这个功能时遇到过坐标换算问题它修改了三次才让我觉得拖动节点时连线曲线不抖动。2.3 白屏排查为什么节点一个都画不出来这个坑几乎每个做画布类工具的人都会踩。项目启动后我打开浏览器页面是正常的工具条也有但画布区域空空如也节点一个都看不到。关键是浏览器控制台没有任何报错这反而更让人头疼。我当时的排查链路大概是这样的先在渲染函数里加了 console.log发现节点数据确实存在说明数据层没问题。再打印 worldToScreen 函数的返回值发现返回的 x 和 y 全部是 NaN。NaN 不会报错但渲染到 canvas 上就是什么都不显示。顺着 NaN 找到根因camera 在渲染函数执行时还是 undefined。初始化顺序出了问题画布控件的渲染时机比 camera 赋值更早。修复方式很简单要么在 camera 未初始化时直接 return 不渲染要么在初始化函数里先赋值 camera 再触发渲染。这个排查花了我大概两个小时因为浏览器不报错真的很迷惑。后来我总结了一条经验遇到“无异常但界面不显示”的情况第一件事就是在坐标变换函数里打印数值检查有没有 NaN。80% 的渲染异常都出在这一层。3. 生图与改图节点让“图生图”真正接管节点输出3.1 文生图节点的参数设计文生图节点是整个流水线的起点它的输入设计直接决定下游改图和视频节点的稳定性。我要求 ds-v4.1-flash 在节点配置面板里把所有参数结构化而不是让用户在一个文本框里乱写。结构化的 JSON schema 大概是这样的{ type: textToImage, config: { prompt: cyberpunk city rooftop, rain, neon lights, woman in raincoat, cinematic, negative_prompt: blurry, low quality, deformed face, extra limbs, width: 1280, height: 720, steps: 30, cfg_scale: 7.5, seed: -1, model: cloud_high_quality } }为什么一定要结构化因为流水线需要稳定复现。你这次用一个自然语言长句描述下次想微调尺寸或者换负向提示词字符串解析很容易出错。参数拆开后每个字段都能单独被子节点引用比如视频节点可以直接读取上游图片的尺寸信息用于分辨率校验。3.2 改图节点局部重绘与面部融合的接入实践改图节点要做的事情远不止“图生图”三个字那么简单。实际创作中改图主要分三种场景局部重绘inpaint、面部修复/融合、整体风格转移。其中“webui 面部融合图生图”是社区里用得最多的玩法本质是在局部重绘管线基础上加入人脸对齐增强。因为很多部署在本地 Stable Diffusion WebUI 的服务都会暴露 HTTP API所以这个节点的实现其实就是构造一个标准请求把上游图片 base64 编码后传给后端。这里给一段常用的 Python 请求示例方便直接复现import base64 import requests with open(upstream.png, rb) as f: img_b64 base64.b64encode(f.read()).decode() with open(mask.png, rb) as f: mask_b64 base64.b64encode(f.read()).decode() resp requests.post( http://localhost:7860/sdapi/v1/img2img, json{ init_images: [img_b64], mask: mask_b64, prompt: woman in raincoat, focused face, detailed eyes, denoising_strength: 0.6, face_fusion: True, }, timeout60, ) # 返回结果里的 images[0] 是修复后的图同样是 base64 out resp.json()[images][0] with open(result.png, wb) as f: f.write(base64.b64decode(out))这里有个容易踩的坑face_fusion 这个参数在不同后端服务里的名字不一样。有的叫 face_fusion有的叫 roop_enabled有的叫 restore_face。对接前一定要先看服务的接口文档比如http://localhost:7860/openapi.json上写清楚参数名再写代码。当时 ds-v4.1-flash 就在参数命名上翻过车生成的代码里用了 openoutpaint 的命名规范实际请求直接被后端拒掉。改图节点的输出处理也很关键。拿到修复图之后不能只把它写在内存里要落盘并更新节点的 outputs 引用这样才能让下游的视频节点读到文件路径。3.3 生图能力模型对比选型不是越贵越好项目里设置了两条生图通道本地小模型通道和云端大模型通道。说真的很多人在选模型时容易走极端追求单次生成质量最高完全忽略成本和速度。对于创作台这种高频迭代工具选型的核心指标其实是“一次跑通率”生成能直接用、不用反复重抽的概率。我当时做了个小规模的生图能力模型对比测试维度就三个生成质量、单次耗时、单位成本。结论也简单通道适用场景特点本地小模型SD 1.5 系列局部重绘、mask 修补、面部融合快、免费、可控细节一般本地中模型SDXL 系列通用生图、风格迁移质量较好速度可接受吃显存云端大模型首帧创意生图、复杂场景质量最高排队久按次计费最后定的是一个“双通道”策略创意首图走云端大模型因为这一步质量天花板很重要后续的局部修改、面部融合、边缘修复走本地模型因为快而且免费还方便连续调多个版本对比。这个策略在后面的流水线运行中帮了大忙改图节点跑几十次也不心疼算力。4. 生视频节点从一帧到几十帧的异步长任务4.1 视频生成接口的调用模型短请求架不住长任务生视频和生图有一个本质差异生图再慢也就是几十秒一个同步 HTTP 请求加超时就能搞定视频生成则要跑几秒到几分钟甚至更久绝大多数云端服务都采用异步任务模式。调用方式一般是先提交一张图片和一段运动描述拿到一个 task_id然后轮询查询任务进度最终拿到视频文件地址。所以我在视频节点里实现了一个小型任务状态机type VideoTaskStatus queued | processing | succeeded | failed; interface VideoTask { taskId: string; status: VideoTaskStatus; inputImage: string; // 上游传入的图片路径 outputVideo?: string; // 最终视频文件路径 progress?: number; // 0~100 }轮询间隔是一个值得注意的细节。我当时把轮询间隔设为 5 秒后来因为服务端任务排队时间远超推理时间又把间隔改成 10 秒同时增加了指数退避策略连续查询到 queued 状态超过三次后间隔翻倍到 20 秒。实时上太频繁的轮询只会更快触发服务端的限流策略纯属自找麻烦。4.2 节点间的图像数据怎么“流”过去这里要解决一个很实际的问题图片数据到底怎么从一个节点“流”到另一个节点一开始我想的是用内存直接传递但很快发现不可行。一方面base64 图片在内存里反复拷贝非常浪费另一方面如果中途某个节点出错前面的中间产物就全丢了重跑成本太高。最终的做法是统一落盘到 artifacts 目录文件名带上节点 ID 和时间戳runtime/ artifacts/ n_01_textToImage_202501101030/raw.png n_02_imageToImage_202501101031/face_fixed.png n_03_imageToVideo_202501101032/final_video.mp4每个节点只通过文件路径引用上游产物。这样做还有个额外好处断点续跑能力。比如改图节点已经跑成功了下游视频节点因为排队超时失败你只需要重跑视频节点改图节点的产物还在不需要全部重新来一遍。这个特性在长链路工作流里非常重要。4.3 “视频全黑”的经典排查视频节点接入后我第一次跑完整链路生视频的结果全是黑的或者开头闪几帧然后就黑屏。我当时检查了模型服务的输入格式发现图片尺寸在支持范围prompt 也没问题然后就去看服务端日志定位到问题出在图片本身还带着透明的 alpha 通道。视频模型在合成时把透明区域渲染成了黑色背景于是整个画面就黑屏了。修复方法很直接在改图节点输出的地方强制把图片转换为不带 alpha 通道的 JPEG 格式同时把 EXIF 方向信息归一化。这两个小处理看起来不起眼但能避免后面所有下游节点的兼容性问题。这里也提醒一下如果你接入的是云端视频生成 API最好在本地先做一次图片预处理再上传不要盲目信任上游直接输出的 PNG。5. 一条流水线从空白画布到成片的完整走通5.1 一个真实的端到端案例前期所有模块都搭好后我第一次完整走通全链路时还是挺激动的。当时的任务设定是生成一段“赛博城市天台人物特写镜头缓慢后拉”的视频片段。在画布上的操作大致分四步拖入一个“文本生图”节点输入正向词cyberpunk city rooftop night, woman in raincoat, neon lights, cinematic尺寸设置 1280×720模型选择云端大模型点击运行。生成首图后拖入一个“改图”节点把上游输出接入原始图端口加载一张参考人脸图启用面部融合同时用局部重绘把背景里一个杂乱的标志牌去掉。拖入“视频”节点把改图节点的输出接入输入端口运动描述写slow pull-back, rain falling, city lights bokeh时长设置 5 秒点击运行。点击画布右上角的“运行整条流水线”按钮剩下就是看着节点状态从 running 变成 done。整个过程的操作次数从原来的十几个步骤压缩到了四次拖拽和三次点击。我后来把这一套流程跑了几十遍中间出问题的节点主要集中在改图和视频生成文生图成功率高一些这个数据在后面总结优化方向时会用到。5.2 流水线调度与手动确认点这里要说明一个设计上的取舍全链路自动跑是顺手的事但我在实现时故意在节点之间加了一个“确认闸门”。意思是说每个节点完成后流水线不会自动冲到下一个节点而是等我在画布上点一下确认按钮才继续。为什么要这么设计因为 AI 生成的中间产物是不稳定的。改图节点这次把脸修得很好下次可能就画崩视频节点等了两分钟最后输出的视频可能镜头乱跳。如果全自动连跑一次失败就会浪费后面所有节点的计算资源。有了手动确认点我可以在每个关键节点检查产物质量不合格就直接重跑当前节点而不是让错误一路传导到最终视频。这个设计后来被我视作整个项目里最值得借鉴的决策之一。它把人的判断力嵌入了自动流水线而不是追求极端意义上的“一键完成”。5.3 实测效果与瓶颈分析我连续测试了十次完整流水线统计了一下各环节耗时和成功率环节平均耗时一次成功率瓶颈说明文生图20~40 秒85%云端排队单次生成质量不稳定改图本地模型5~15 秒92%速度快失败主要原因是蒙版位置偏差生视频90~180 秒70%排队时间长视频模型对输入图敏感整体来说链路是通的产出的视频素材用在短视频平台做混剪完全够用。瓶颈也清晰视频生成环节最久也最容易失败。想要让整个流水线更可靠方向就是提升视频模型的输入兼容性比如在送入视频节点前做自动分辨率对齐和图像增强。6. 开发过程中的意外收获避坑、复用与扩展6.1 大模型辅助开发的“三明治工作法”这次项目让我对大模型辅助编程有了完全不同的理解。让 ds-v4.1-flash 从零写项目最忌讳的就是扔一句话需求让它直接输出全部代码然后期待它一次跑通。那样子单次跑通率大概不到三成而且出了问题你都不知道该从哪里排查。我后来摸索出一套固定的“三明治”工作流先自己定接口和数据契约比如上面讲到的 Camera、FlowNode、Connection 这些类型定义然后让模型基于这些类型去填实现。让模型生成一个模块的代码立刻跑最小验证。比如生成完“节点拖拽”功能就只测拖拽这一个行为别等整个画布完成。拿到运行反馈后把报错信息和预期行为原样丢给模型让它自改我只看关键的坐标换算和状态更新逻辑。有了契约后模型的“一次跑通率”明显提升尤其是它不会再凭空发明结构体和变量名。我写了一个 prompt 模板固定要求“先读>