关于 framefields-render
framefields-render 是面向使用 framefields 的开发者和创作者的无头渲染技能指南,说明如何在 Node.js 中验证并导出视频。其验证流程从类型检查、单帧和帧网格检查逐步推进到预览与完整编码。
主要功能
- 01在 Node.js 中无头渲染
- 02进行类型与运行时模式验证
- 03渲染单帧和帧网格
- 04通过 Vitest 编写像素与布局测试
- 05预览并导出 MP4 或 WebM
适用场景
- 01在完整编码前检查关键帧布局
- 02通过帧网格核对动画与转场
- 03自动测试输出画面的颜色、透明度或边界框
- 04供用户预览作品并反馈修改
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ gatewai-dev/ framefields --skill framefields-render安装后在 Claude Code 中输入/framefields-render确认已加载。
详细介绍
framefields-render
设置
从 framefields npm 包导入所有内容。如果项目尚未设置 framefields,请先按照 framefields 技能操作(安装、项目脚手架、首次渲染)。
概述
framefields 基于原生 WebGPU(Dawn)在 Node.js 中无头渲染,通过 FFmpeg 编码;可用时使用硬件编码器(NVENC、VideoToolbox),否则使用软件编码器。它不会运行浏览器、Puppeteer 或 Chromium。
由于视频渲染需要大量资源,且原生 GPU 管线会在工作线程池中异步运行,framefields 工作流要求严格遵循验证阶梯:先检查开销较低的中间产物,再开始完整视频渲染。
5 步验证阶梯
务必按顺序逐步验证。在第 1 或第 2 步发现布局或时序问题只需几秒;若等到 30 秒的 4K 视频渲染结束后才发现,则会浪费数分钟。
第 1 步:静态类型检查和架构验证
在使用 GPU 之前,验证 TypeScript 类型,并根据严格的运行时架构解析合成:
import { CompositorProgramSchema } from "framefields";
// 1. Compile spec
const spec = comp.toSpec();
// 2. Validate against strict schema
const parsed = CompositorProgramSchema.safeParse(spec);
if (!parsed.success) {
console.error("Schema validation failed:", parsed.error.format());
process.exit(1);
}
// 3. Lint: things that render without error but not as intended
for (const d of comp.lint()) console.log(`${d.severity} ${d.code}: ${d.message}`);
对项目进行类型检查:
npx tsc --noEmit -p .
使用当前 CLI 初始化的项目中,pnpm check 会运行类型检查和文档预检。对于其他入口,可运行 pnpm exec framefields check src/film.ts --export buildFilm --json。导出内容应为一个 Composition 或返回 Composition 的构建器;导入它会执行项目代码,因此请勿在构建器中执行渲染、生成或写入操作。CLI 会在隔离进程中运行它,将构建器日志发送到 stderr,并将一个带版本号的 JSON 结果写入 stdout。发生错误时命令会失败;使用 --warnings-as-errors 时,出现警告也会导致命令失败。--timeout 60000 会限制工作进程的运行时长。
pnpm check:render 会额外执行 GPU 验证、渲染一帧,并生成三点网格图,输出到 output/check;使用 --output <dir> 可更改位置。此冒烟检查不能替代针对性的像素断言或视频 QA。GPU 检查失败或不可用时会报告为失败。使用 pnpm exec framefields api 获取根据此 SDK 版本生成的时序、网格和预检类型声明。
第 2 步:单帧检查(renderFrame)
将关键帧点(例如开场、动作峰值、结尾)分别渲染为磁盘上的 PNG 文件,以检查对齐、排版、颜色和不透明度:
import fs from "node:fs/promises";
import path from "node:path";
import { HeadlessMediaRenderer } from "framefields";
const renderer = new HeadlessMediaRenderer();
// Render frame 45 (1.5s at 30fps)
const pngBuffer = await comp.renderFrame({
frame: 45,
renderer,
});
await fs.writeFile("output/frame-45.png", pngBuffer);
如果同时提供 frame 和 atMs,则优先使用 atMs,帧钩子也不例外。帧索引必须为非负安全整数;时间戳和 FPS 支持小数。合成的 FPS 具有权威性:如果渲染或网格的 FPS 覆盖值不同,则会抛出异常。媒体管线仍可选择其渲染 FPS。
第 3 步:运动和布局稳定性联系表(renderFrameGrid)
无需编码完整视频,即可生成连续帧网格图来验证缓动曲线、剪切点、动态扫动或文本换行:
import fs from "node:fs/promises";
import { renderFrameGrid } from "framefields";
// Samples frames across the intro sweep (frames 0 to 60)
const gridPng = await comp.renderFrameGrid({
frames: [0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
columns: 4,
cellWidth: 480, // Downscaled thumbnail size for rapid visual scanning
showLabels: true, // frame number and timestamp under each cell
});
await fs.writeFile("output/intro-grid.png", gridPng);
联系表可立即揭示:
- 运动卡顿和缓动减速不平滑。
- 文本裁切,或在关键帧边界处发生换行跳变。
- 图层 z-index 设置错误,或缺少剪切转场。
网格帧范围包含首尾帧,但合成时长不包含结束时刻。自动采样会在导出的帧数之前停止(max(1, round(durationMs * fps / 1000)))。为便于诊断,显式指定的采样点可以超出导出窗口。采样顺序和重复项会保留。帧步长必须为正安全整数,时间戳步长必须为有限正数。生成采样点时会检查采样数量限制(默认 64,最多 120);请求无效或超出限制时,会在创建大型列表之前失败。标签描述实际选中的帧;时间戳仅为显示而格式化。
第 4 步:在 Vitest 中进行程序化一致性探测
编写自动化的无头 vitest 测试,断言像素颜色、透明度或布局边界框:
import { describe, it, expect } from "vitest";
import { HeadlessMediaRenderer } from "framefields";
import { buildFilm } from "../film.js";
describe("Hero Scene Conformance", () => {
it("renders non-black title text on frame 30", async () => {
const comp = await buildFilm();
const renderer = new HeadlessMediaRenderer();
const png = await comp.renderFrame({ frame: 30, renderer });
expect(png.length).toBeGreaterThan(1000);
// Use skia-canvas or pixel probes to assert color at target coordinates
});
});
第 4.5 步:导出前向用户展示(preview)
当流程中有人参与时,先让他们查看合成效果,再花时间进行最终渲染。startPreview 会在 localhost 上提供一个播放器,其中包含音轨、带波形的时间轴以及逐帧浏览功能。页面会加载合成内容自身的代码,并在浏览器中使用 WebGPU 实时渲染:无法及时绘制每一帧的设备会跳过部分帧,而声音会保持原速;服务端只提供该代码、项目文件以及由导出引擎混合的音轨。将其指向用于构建合成内容的模块:
import { startPreview } from "framefields";
const session = await startPreview(
{ entry: new URL("./film.ts", import.meta.url), export: "buildFilm" },
{ title: "Launch film" },
);
console.log(`Preview at ${session.url}`);
await session.closed; // keep the process alive while the user watches
- 导出内容是一个返回
Composition的函数(可以是异步函数),也可以是Composition本身。浏览器会调用它,因此其中的构建时 Node 代码必须保持简单:node:path、fileURLToPath(import.meta.url)和同步fs读取(JSON、文本)可通过 shim 工作;写入会被跳过。通过绝对路径引用的资源会从服务端加载,范围限于包含入口文件的项目(最近的.git或package.json所在目录);对于保存在该范围之外的资源(例如共享字体文件夹),请传入包含一个或多个路径的root。 - 在后台运行,并读取输出。 将脚本作为后台命令启动,并使用运行环境允许的最长超时时间;告知用户 URL,并在聊天中征求反馈。命令输出(stderr,前缀为
[framefields preview])会提示页面何时打开、报告页面中的每种不同错误各一次(构建错误、帧抛出错误、字体或渲染器加载失败、没有 WebGPU),并说明停止原因。页面打开后请检查输出,不要想当然地认为影片已成功绘制。log会重定向或静默这些行;session.closed会以停止原因为结果解析,原因包括"idle"、"replaced"或"closed"。 - 整个循环只用一个标签页。 端口按项目目录固定。再次运行预览(完成修改后)会接管已在运行的预览;保持打开的标签页会自动重新连接,并在原先显示的位置加载新版本。不要让用户重新打开任何内容。用户关闭标签页后,预览会在约 5 秒后自行停止(重新加载不会停止预览;
idleCloseMs可更改延迟时间)。如果预览已停止(标签页关闭、达到时间限制,或你终止了它),只需重新启动。 - 固定在画面上的批注是用户精确的反馈。 暂停时,用户点击帧(或拖动选择区域),并写下需要修改的内容;批注会保留帧、位置(以帧的宽高比例表示,
x、y;区域则还包括w、h)以及文本。每条批注都会输出(note 3 at 0:12.50 (frame 750), spot 42%,31%: …),并附带note-<id>.jpg路径、标出位置的帧;所有批注也保存在.framefields/preview-notes/notes.json中(.framefields在 git 中会忽略自身;notesDir可更改其位置,设为false则关闭批注)。处理批注的方法:读取notes.json,打开每条未完成批注的图片,准确查看其指向的位置,根据时间和位置在代码中找到对应图层,进行修改,然后重新运行预览。标签页会带着批注重新打开,供用户检查并勾选完成。你也可以自行在notes.json中设置"done": true。无法读取文件的聊天可通过 Notes 面板中的 Copy 按钮获取相同列表。 - 定位到特定时刻。 URL 中的
#t=12.5会让播放器从 12.5 秒处打开(按帧定位时使用#frame=750);播放头停止后,地址栏会跟随其位置,因此你提供给用户的链接会跳转到指定位置。 - 通过脚本控制,而非点击操作。 如果你能在页面中运行 JavaScript(例如使用 Agent 浏览器),请使用
window.framefieldsPreview:await seek(seconds)或await seekFrame(n)会在相应帧绘制完成后解析(此时再截屏);play()和pause()可控制播放,notes()会列出批注,state()会返回{ state: "loading" | "ready" | "error", error, frame, time, playing, buffering, audio, connected, playbackFps, renderMs, meta }。<html data-preview="ready">表示首帧已绘制。隐藏标签页会暂停播放,因为浏览器会对其限流。 - 在用户当前使用的地方展示。
startPreview只会提供页面并返回 URL。如果你的应用内置了浏览器,请在那里打开该 URL。否则,将链接提供给用户,或传入open: true以打开用户的默认浏览器(若已有打开的标签页会自行重新连接,则不会执行此操作)。页面需要 WebGPU(当前版本的 Chrome、Edge 或 Safari)。 - 在任何设备上实时播放。 播放时会绘制时钟当前对应的帧,并跳过错过的帧,因此会立即开始,声音也会保持原速;速度较慢的设备会显示较少帧数。时间码旁会显示播放达到的帧率,与影片自身的
fps相互独立;state()会将其报告为playbackFps,renderMs表示绘制上一帧所需的时间。暂停时,页面会绘制播放头所在的精确帧。视觉效果会基于前一帧绘制每一帧并进行分析:暂停时,页面会向前分析最多 20 秒,因此该部分可以准确播放;超过这段时长后,跳帧的设备会显示挖空效果,并根据最后绘制的帧进行跟踪。请在暂停状态下或在导出内容中检查这些效果。 - 音频与
renderVideo混入的音轨相同(audio: false会跳过音频)。播放时以音轨作为时钟;如果某帧耗时较长(例如视频跳转、复杂镜头),画面会跳帧以追上声音。
第 5 步:导出完整视频(renderVideo)
验证静态帧和运动网格后,编码最终的 MP4/WebM 视频:
import path from "node:path";
const result = await comp.renderVideo({
outputPath: "output/final-video.mp4",
quality: "high", // "draft" | "medium" | "high" | "lossless"
codec: "avc", // "avc" (H.264) | "hevc" (H.265) | "vp9" | "av1"
audioCodec: "aac",
concurrency: 4, // Parallel frame encoding threads
});
// Always invoke cleanup to release temporary frame cache and file handles
await result.cleanup?.();
传入 qa: true,让引擎在渲染时检查输出(参见下文的 内置视频 QA),并将检查结果作为 result.qa 返回。
无 GPU 时渲染
Agent 通常运行在没有 GPU 的沙盒或 CI 运行器中。此时,framefields 会改用 CPU 渲染,所有功能的输出都保持一致(文本、媒体、Lottie、特效、3D、视觉;视觉推理也会改用 CPU)。
- Linux: 安装 Mesa lavapipe:
apt-get install -y libvulkan1 mesa-vulkan-drivers。如果完全没有 Vulkan 驱动,则无法渲染。 - Windows: 内置 Direct3D 12 的 WARP 渲染器;也可以通过 Vulkan 使用 lavapipe(例如来自 mesa-dist-win)。
- macOS: 始终配备 Metal GPU,包括虚拟机;framefields 的 WebGPU 构建版本不支持 Vulkan,因此不会在此处使用 lavapipe。
- 确认方式:
await describeGpu()会返回software: true。 - 预留运行时间: 在我们的 Linux 测试中,速度约比 Apple M1 Pro 的 GPU 慢 12 倍(测试项目中的三段视频耗时 9.7 分钟,而 GPU 耗时 49 秒);速度会随 CPU 核心数变化。帧、网格和短视频导出都没问题。视觉层最耗时。
- 长视频: 在配备 GPU 的机器上渲染,或将项目推送并提交给 framefields 渲染工作器(使用 Docker 或 RunPod,在 GPU 上渲染某个 git commit;如果需要身份验证,请让用户在进程环境中设置
FRAMEFIELDS_API_KEY,并使用该密钥,但不要打印或嵌入它)。项目检查清单和作业格式见:https://framefields.dev/docs/guides/render-service/
生产环境渲染脚本模式
生产项目会对 src/render.ts 进行结构设计,以便通过 CLI 同时支持快速帧探测和完整导出:
// src/render.ts
import fs from "node:fs/promises";
import path from "node:path";
import { HeadlessMediaRenderer, startPreview } from "framefields";
import { buildFilm } from "./film.js";
const OUT = path.resolve(import.meta.dirname, "../output");
const [mode = "video", ...args] = process.argv.slice(2);
if (mode === "preview") {
// The browser runs film.ts itself; no need to build the film here.
const session = await startPreview({ entry: new URL("./film.ts", import.meta.url), export: "buildFilm" });
console.log(`Preview at ${session.url}`);
await session.closed; // serves until its tab closes or the next preview replaces it
process.exit(0);
}
const comp = await buildFilm();
await fs.mkdir(OUT, { recursive: true });
if (mode === "frames") {
const renderer = new HeadlessMediaRenderer();
await fs.mkdir(path.join(OUT, "frames"), { recursive: true });
for (const f of args.map(Number)) {
const png = await comp.renderFrame({ frame: f, renderer });
const file = path.join(OUT, "frames", `f${String(f).padStart(4, "0")}.png`);
await fs.writeFile(file, png);
console.log(`Rendered frame: ${file}`);
}
} else {
const started = Date.now();
const result = await comp.renderVideo({
outputPath: path.join(OUT, "output.mp4"),
quality: "high",
});
await result.cleanup?.();
console.log(`Exported video: ${result.filePath} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
// CRITICAL: Always explicitly exit process to prevent native WebGPU/mediabunny thread hangs
process.exit(0);
CLI 用法:
# Render specific keyframes for instant review:
tsx src/render.ts frames 0 18 36 72
# Live player for the user (run in the background; re-run after changes and the open tab reloads):
tsx src/render.ts preview
# Render full master video:
tsx src/render.ts
查看渲染进度
renderVideo 会将自身的进度记录输出到 stderr,每个事件输出一条简短的 [framefields] 行。无论视频多长,日志都很简短;导出时大约会有 15 行:
[framefields] Preparing 1800 frames (60.0s) at 1920x1080, 30 fps, h264
[framefields] Rendering 1800 frames
[framefields] Rendered 10% (180/1800 frames) · 41.2 fps · eta 39.3s
…
[framefields] Rendered 100% (1800/1800 frames) · 41.8 fps · in 43.1s
[framefields] Mixing audio
[framefields] Encoding 60.0s of audio (aac)
[framefields] Muxing mp4
[framefields] Done in 51.4s (14.2 MB): prepare 1.2s · render 43.1s · audio 3.2s · audio-encode 1.1s · mux 0.8s
- 最后一帧完成后仍会继续工作。“混合音频”会解码影片中的所有音频和视频片段,然后编码配乐,并通过“复用”写入文件。片段数量多或时长长时,这可能需要一些时间。这不是卡住了;请等待
Done in …。 - 安静阶段会输出
Still …行,例如Still mixing audio (42%, 3/7 clips: music.mp3), 1m 10s in this phase:等待 10 秒、30 秒、70 秒后开始逐渐延长间隔,最终每 5 分钟输出一行。每行都会说明正在运行的阶段和已持续的时间。只有在远超上次间隔时间仍未收到任何行时,渲染才可能卡住。无论何时终止进程,都不会留下视频。 - 失败信息会指出所处阶段:
Failed while mixing audio after 12.3s,后面会附上错误信息。 - 在后台运行长时间导出,并使用执行环境允许的最长超时时间;完成后再读取输出,不要反复轮询。
LOG_LEVEL=warn会隐藏这些行;LOG_LEVEL=debug会显示逐阶段的详细信息。警告和错误会显示为[framefields] warn: …/error: …。- 设置
NODE_ENV=production(或FRAMEFIELDS_LOG_FORMAT=json)时,这些行会改为以 pino JSON 格式输出到 stdout,文本位于msg字段中;FRAMEFIELDS_LOG_FORMAT=text则会强制输出简短行。 - 如需输出自己的进度信息,
onProgress会接收每个事件,格式为{ phase, frame, totalFrames, elapsedMs, phaseProgress?, fps?, etaSec?, detail? }。phase依次包括prepare、render(仅输出音频时会跳过)、audio、audio-encode、mux、convert(仅 GIF)和done。
内置视频 QA(renderVideo({ qa }))
引擎会在渲染时测量视频:每一帧在交给编码器读取时都会测量,音频则在混音时测量。无需进行第二次解码,测量结果准确反映实际编码的内容。
import { formatQaReport } from "framefields";
const result = await comp.renderVideo({
outputPath: "output/final-video.mp4",
qa: { targetLufs: -14 }, // or `qa: true` for defaults (no loudness target)
});
console.log(formatQaReport(result.qa!));
if (!result.qa!.passed) process.exitCode = 1; // fail CI / the agent loop
result.qa 采用结构化数据,因此应根据它进行判断,而不是解析日志:
issues[].code | 严重程度 | 含义 |
|---|---|---|
frames_missing | 错误 | 编码的帧数少于合成中的帧数 |
black_frames | 警告 | 黑帧时长 ≥ minBlackSec(0.5 秒);包含 startSec/endSec |
audio_clipping | 警告 | 样本达到或超过满刻度 |
loudness_off_target | 警告 | 综合响度与 targetLufs 相差超过 lufsTolerance(1 LU) |
animation_truncated | 警告 | 图层剪辑窗口之后的关键帧不会播放;包含 layerId |
frozen_video | 信息 | 画面连续 ≥ minFrozenSec(2 秒)没有变化;静止的标题卡片不受影响 |
audio_silence | 信息 | 音频低于 silenceDbfs(-60)的时长 ≥ minSilenceSec(2 秒) |
audio_silent | 信息 | 整个音轨无声 |
invalid_gradient | 错误 | background/fill 渐变字符串无法解析,因此不会绘制任何内容 |
unsupported_image_format | 错误 | 图像图层接收到 .json/.lottie 矢量动画、视频或音频文件 |
unknown_color | 警告 | 颜色字符串不是有效颜色,因此会绘制为透明 |
low_contrast_text | 警告 | 文本与其后方纯色背景的对比度低于 3:1(会建议使用可读的颜色) |
offscreen_layer | 警告 | 静态绝对定位图层完全位于画布之外 |
shadow_unsupported_light | 警告 | 对点光源或环境光设置了 castShadow |
pbr_without_environment | 信息 | 设置了 roughness/metallic,但没有 Layer.environment()(不会产生物理着色效果) |
multiple_shadow_lights | 信息 | 存在多个设置了 castShadow 的光源;只有第一个会投射阴影 |
static_track | 信息 | 轨道上的所有关键帧都保持相同的值 |
文档检查也会在渲染前独立运行:comp.lint() 会返回相同的 { severity, code, message, layerId } 条目。请在第 1 步中,紧接着类型检查之后运行。
只要存在任何错误或警告,passed 就为 false。qa.audio.integratedLufs 是 ITU-R BS.1770 响度(流媒体平台会将响度标准化到约 -14 LUFS);qa.audio.peakDbfs 是样本峰值。
使用 ffmpeg 检查已完成的文件
对于在其他位置渲染的文件,或要与之前的渲染结果进行比较时,可使用 ffmpeg/ffprobe 进行检查:
# 1. Check video and audio stream properties
ffprobe -v error -show_entries stream=codec_name,width,height,duration,r_frame_rate output/final-video.mp4
# 2. Detect unexpected black frames or dead transitions
ffmpeg -i output/final-video.mp4 -vf "blackdetect=d=0.5:pix_th=0.10" -f null -
# 3. Verify audio loudness levels (prevent clipping / silence)
ffmpeg -i output/final-video.mp4 -af "volumedetect" -f null -
常见渲染故障模式
| 症状 | 根本原因 | 解决方案 |
|---|---|---|
| 整个画面渲染为黑色 | 缺少字体注册,或 uniform 中包含 NaN | 确保通过 FontManager.register() 注册 Layer.text 中使用的每种字体,并将其列入 comp.fonts。检查所有信号输出是否都是有限数值。 |
| 媒体层不可见/空白 | 输入句柄绑定错误,或不透明度为零 | 检查媒体路径是否存在,以及 opacity > 0。如果使用效果或片段,确保设置 fit: "cover" 且容器尺寸有效。 |
| 音频提前中断 | 音频层缺少 durationFrames | 在 Layer.audio() 上显式设置 durationFrames: totalFrames。 |
单次 renderFrame 中视觉抠像/蒙版为空 | 视觉处理读取的是上一帧渲染结果;第 0 帧没有上一帧 | 使用同一个 HeadlessMediaRenderer 连续渲染两帧或更多帧,并保留最后一帧。renderVideo 会自动处理。 |
| 视觉抠像中的主体出现重影 | 帧渲染顺序错乱(帧网格,或从第 3 帧跳到第 48 帧),导致抠像使用了其他帧的像素 | 使用连续帧或导出的 MP4 检查视觉层,不要使用 renderFrameGrid。 |
输出在 Rendered 100% 后停止 | 正在混合、编码或复用音频 | 这不是卡住:请等待 Still … 行和 Done in …(参见读取渲染进度)。 |
脚本在 Done in … 后卡住 | 后台工作线程池仍在运行 | 请务必在渲染脚本末尾加入 process.exit(0)。 |
| Linux 服务上的渲染速度很慢 | 帧由 CPU 适配器(lavapipe)绘制 | await describeGpu() 会显示 software: true;请修复 GPU 的 Vulkan 驱动(vulkaninfo --summary)。 |
编码器无法启动(h264_rkmpp、NVENC) | framefields 2.2 之前的版本使用了未经测试的硬件编码器 | 使用 framefields 2.2+,它会回退到 libx264 等编码器;或者设置 FRAMEFIELDS_HW_CODECS=off。 |
| 渲染工作任务失败 | 项目尚未准备好渲染(锁文件、导出、LFS) | 查看任务的 code 和 hint;检查清单见 https://framefields.dev/docs/guides/render-service/ |
| 文本看起来错位或被截断 | 布局节点框小于文本字符串所需空间 | 显式设置换行 width(例如 width: 1400),或使用 width: "fill"。 |
| 颜色看起来褪色/边缘发暗 | Alpha 混合未预乘 | 引擎使用预乘 Alpha。确保自定义着色器使用 srcFactor: "one"。 |