mcpField
F
framefields-compose

framefields-compose

github.com/gatewai-dev创意与媒体497109 次安装今天更新
代码仓库

关于 framefields-compose

framefields-compose 是用于 framefields 的技能,帮助制作由场景、图层和关键帧动画组成的视频。它提供组合式场景构建,以及文本、媒体、图形、3D 和动画原语。

主要功能

  1. 01按场景组织视频结构
  2. 02使用图层组合文本与媒体
  3. 03创建关键帧和程序化动画
  4. 04构建图表与矢量图形
  5. 05加入 3D 模型和灯光

适用场景

  1. 01构建多场景商业视频
  2. 02制作节拍同步的动画
  3. 03组合字幕、图表和媒体素材

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/gatewai-dev/framefields --skill framefields-compose

安装后在 Claude Code 中输入/framefields-compose确认已加载。

详细介绍

framefields-compose

设置

从 framefields npm 包中导入所有内容。如果项目尚未设置 framefields,请先遵循 framefields 技能(安装、项目脚手架、首次渲染)。

概述

framefields 是一个代码优先、结果确定的视频合成 SDK,可在 Node.js 中原生运行于 WebGPU(Dawn)上。它以高性能 GPU 流水线、Yoga 弹性布局、Slug 矢量排版、3D 摄像机变换和响应式音频/信号驱动器,取代基于浏览器的渲染(Puppeteer/Chromium)。

每个合成都定义为一棵布局节点树,并编译成一个结果确定的执行程序:(composition, frame) → pixels。


架构与核心概念

1. 合成生命周期

Composition 实例定义画布尺寸、帧率、背景、已注册字体、全局音轨和图层层级结构:

import { Composition, Layer, FontManager } from "framefields";

// Always register fonts before assembling layers
await FontManager.register({
  family: "Inter",
  source: "assets/fonts/Inter.ttf"
});

const comp = new Composition({
  width: 1920,
  height: 1080,
  fps: 30,
  durationMs: 10000, // or computeDuration()
  backgroundColor: "#0E0D0C",
  fonts: ["assets/fonts/Inter.ttf"],
});

comp.add(Layer.box({ /* ... */ }));
comp.add(Layer.audio("assets/score.mp3", { volume: 1 }));
2. 场景架构(生产模式)

对于广告、电影和多镜头视频,请使用场景本地帧,将项目拆分为离散的模块化场景函数:

  • 先定义令牌(theme.ts): 集中管理调色板、排版、缓动曲线和锁定节奏的节拍网格(BEAT = Math.round(fps * 60 / BPM))。切勿使用任意边距或临时指定的十六进制颜色代码。
  • 场景模块(scenes/hero.ts): 每个场景都返回一个带有 startFrame 和 durationFrames 的全帧根框。子元素相对于场景起始位置定位(frame: 0 = 场景开始)。
  • 主装配(film.ts): 导入场景,将其挂载到 comp.add(scene()),并添加主音轨以及合成级别的调色/颗粒效果。

对于导出的 Scene / Sequence 构建器,场景时间窗口采用左闭右开区间:[startFrame, startFrame + durationFrames)。在 60 帧场景中,本地帧 30 开始的根图层默认持续 30 帧。显式设置的根图层时间窗口必须位于其场景范围内;起始帧无效或超出范围时,会报错并指出场景和图层 ID。子元素及其关键帧仍使用相对于父元素的本地帧。出场重叠会放置下一个场景,且不得超过相邻任一场景的时长。最后一个场景没有后续目标,因此其出场重叠不会缩短时间线。calculateTimeline().totalFrames 是最大的场景结束帧(不包含该帧)。

// theme.ts
export const W = 1920;
export const H = 1080;
export const FPS = 30;
export const BEAT = 18; // 100 BPM at 30 fps
export const beats = (n: number) => Math.round(n * BEAT);

export const INK = "#0E0D0C";
export const PAPER = "#EEE9E0";
export const EMBER = "#FF5A1F";

export function scene(options: { id: string; from: number; to: number; children: unknown[] }) {
  return Layer.box({
    id: options.id,
    position: "absolute",
    x: 0,
    y: 0,
    width: W,
    height: H,
    startFrame: options.from,
    durationFrames: options.to - options.from,
    children: options.children as never,
  });
}

图层基元(Layer.*)

所有 Layer.* 构建器都会返回 AnimatableNode<T> 实例,支持链式调用 .animate()、.apply() 和效果方法。

构造器节点 kind描述和关键选项
Layer.text(content, options)"text"Slug GPU 矢量文本。选项:fontSize、fontFamily、fill、letterSpacing、lineHeight、align、direction、animators、spans。阿拉伯语、希伯来语和波斯语会自动进行字形塑形并从右向左排版(direction: "auto");设置 "rtl"/"ltr" 可强制指定方向。align: "start"/"end" 会遵循文字方向。
Layer.box(options)"box"矩形容器/卡片。选项:background、borderRadius、borderColor、borderWidth、overflow: "hidden"、padding、children。
Layer.flex(options)"flex"自动布局容器(Yoga)。选项:dir: "row" | "column"、gap、justify、align、padding、wrap、children。
Layer.video(src, options)"media"通过 mediabunny 解码的视频图层。选项:fit: "cover" | "contain"、trimStartSec、muted、volume。
Layer.image(src, options)"media"静态光栅图像(.png、.jpg、.webp)。选项:fit: "cover" | "contain"。矢量动画(.json、.lottie)请使用 Layer.lottie。
Layer.svg(src, options)"media"矢量 SVG 资源。选项:fit: "contain"。
Layer.lottie(src, options)"media"Lottie 矢量动画文件(.json、.lottie)。通过 ThorVG 解码并渲染。选项:fit: "contain" | "cover" | "fill"、width、height。
Layer.audio(src, options)"media"音频轨道图层。选项:volume、startFrame、durationFrames。
Layer.shape(type, options)"shape"参数化矢量图形("rect"、"circle"、"ellipse"、"polygon"、"star"、"arrow"、"path")。选项:fill(颜色或 CSS 渐变)、d(含弧线的 SVG 路径数据;可为其设置关键帧以变形)、strokeWidth、strokeColor、trimStart、trimEnd。
Layer.particles(options)"media"GPU 粒子系统(参见 GPU Particles)。
Layer.vfx(options)"media"程序化全帧视觉特效:烟雾、火焰、雨、雪、极光……(参见 Procedural VFX)。
Layer.caption(src, options)"media"由 SRT 驱动的同步字幕引擎。字幕提示自动换行并锚定在底部。
Layer.chart(options, props)"box"使用原生节点构建的图表(d3 比例尺和几何图形)。type:"line" | "area" | "bar" | "scatter" | "candlestick" | "pie" | "donut"。选项:width、height、series: [{ name, data, color, type, curve, pointRadius }] 或 data、categories、stacked、xAxis/yAxis(min、max、ticks、grid、format:类似 "$,.0f" 的 d3-format 字符串)、legend、valueLabels、fontFamily、fontSize、colors、animate: { start, duration, stagger, ease } | false。props 用于定位图表框。子节点的 ID 为 <id>-bar-<series>-<i>、<id>-line-<series>、<id>-slice-<i>。散点图请控制在几百个点以内:每个点都是一个节点。
Layer.camera(options)"camera"3D 场景摄像机,支持视场角、镜头焦点切换和轨道坐标。
Layer.light(options)"light"3D 光照图元(ambient、directional、point、spot)。directional 和 spot 支持 castShadow: true(每个场景一个)、shadowSoftness、shadowDarkness。
Layer.environment(preset, options)"light"3D 场景的环境:基于影像的工作室灯光、曝光、色调映射、雾效、泛光。**添加此项会将场景切换为基于物理的着色。**参见下文的 3D Look。
Layer.sequence(entries, options)"box"将场景首尾相接,并在场景之间加入 Transition;返回一个 totalFrames 为总长度的框。参见 Scenes & Transitions。
Layer.model(options)"model3d"3D 网格加载器(.gltf、.glb、.obj、.fbx、.stl、.ply)。支持蒙皮和音频变形计算着色器。
Layer3D.roundedBox / sphere / cylinder(options)"model3d"程序化实体网格,以 x/y/z 为中心(y 轴向下,因此“顶部”为 -y)。roundedBox:width、height、depth、radius、segments、taper(顶面缩放比例,键帽可设为 <1)。sphere:radius。cylinder:radius 或 radiusTop/radiusBottom、height、openEnded。color 会烘焙到网格中;tint 会与其相乘,并作为 color 属性进行动画处理。表面属性:roughness、metallic、emissive、emissiveIntensity、castShadow。wireframe: true 会以单像素线条绘制网格三角形边缘,包括三角剖分边和背面边。形状相同的网格共享一个 GPU 缓冲区,因此复制数百份也很轻量。

媒体时间重映射

Layer.ambientLight({ color, intensity, id }) 和 Light.ambient({ color, intensity, id }) 接受与其他灯光辅助方法相同的选项对象;仍支持位置参数形式 (color, intensity, options)。通过图层的 animation 选项传入的动画构建器实例会被规范化为程序规格,包括 CameraAnimation。

视频和音频图层支持 timeRemap,并需显式指定 durationFrames。映射帧为图层本地帧;sourceSec 为绝对源时间。源时间递增时正向播放,递减时反向播放,相同时定格画面并静音。速率变化也会改变音频音高。不要将其与 trimStartSec 一起使用。

const clip = Layer.video("assets/clip.mp4", {
  durationFrames: 90,
  timeRemap: { kind: "keyframes", keyframes: [
    { frame: 0, sourceSec: 1, interpolation: "smooth" },
    { frame: 30, sourceSec: 2 }, { frame: 45, sourceSec: 2 },
    { frame: 90, sourceSec: 1 },
  ] },
});
comp.add(clip);
const vision = comp.vision(clip);
// Or: timeRemap: { kind: "rate", sourceStartSec: 3, rate: -0.5 }

也可以用信号驱动时钟:{ kind: "signal", mode: "speed", signal, sourceStartSec } 会对播放速率(每秒对应的源秒数)积分;负值表示反向播放,0 表示保持画面;mode: "source-time" 则读取绝对源秒数。signal 可以是 Signal,也可以是合成信号的名称(comp.addSignal)。它会在每个图层帧边界根据合成时钟读取,并在边界之间插值,因此画面、音频和 comp.vision 共用同一时钟。

const speed = Signal.state(1);
comp.onRequestFrame((ctx) => speed.set(ctx.frame < 48 ? 1 : 0.25));
comp.add(Layer.video("assets/clip.mp4", {
  durationFrames: 96,
  timeRemap: { kind: "signal", mode: "speed", signal: speed },
}));

若没有帧钩子(onRequestFrame、onPrepareFrame、comp.vision、物理效果),信号会预先采样。若存在这些钩子,信号则会实时读取:每个边界只记录一次,且在该帧的 onRequestFrame 钩子运行之后记录,绝不会超前于正在渲染的帧。向前跳转时,会按顺序运行被跳过帧的钩子;向后跳转时,则会重用已记录的历史数据。每帧记录时都会检查源范围;若图层时长超过合成时长,则保持最后一个值。若信号读取其所属图层当前的源时间,则会报告循环依赖。

关键帧必须从帧 0 开始,在 durationFrames 结束,并按帧递增。interpolation 属于片段的起始关键帧,可选 "linear"(默认)、"ease-in"、"ease-out" 或 "smooth"。这些曲线均为单调曲线,不会超调;smooth 的起始和结束速度为零。相邻片段在连接处可以具有不同速度。comp.vision(layer) 要求图层已添加;片段分析按源顺序运行,并在映射后的时钟上采样。效果仍使用合成时钟。渲染前会检查源范围。音频使用重采样,而非保留音高的变速处理。由于需要在源中寻址,反向视频的渲染速度可能较慢;无头解码时,每个解码器最多缓存 48 MiB 的帧。

动画系统(LayerAnimation)

使用 LayerAnimation.create() 或流畅工厂方法创建动画。动画由关键帧轨道或程序化驱动器组成。

1. 关键帧过渡(fromTo)
import { LayerAnimation } from "framefields";

// Fluent chain
const anim = LayerAnimation.create()
  .fadeIn(0, 20, "power2.out")
  .fromTo("y", 60, 0, { start: 0, end: 24, ease: "expo.out" })
  .fromTo("scale", 0.95, 1.0, { start: 0, end: 30, ease: "power3.out" });

// Easing bases: none, power1, power2, power3, sine, circ, expo, back, elastic, bounce, spring, cubic, hold
// Directions: in, out, inOut (e.g. "expo.out", "back.out(1.7)", "power2.inOut")

若要为同一属性设置两个以上的关键帧,可用 keys 列出:每个元组为 [frame, value, ease?],其中 ease 控制运动进入该关键帧的方式。帧会四舍五入,因此可以直接使用节拍计算式(at + BEAT / 2):

const pop = LayerAnimation.create()
  .keys("scale", [[0, 0], [10, 1.12, "back.out(1.6)"], [16, 1, "power2.out"]])
  .keys("opacity", [[0, 0], [6, 1]]);
// Static form: LayerAnimation.keys("rotation", [[0, -8], [20, 0, "expo.out"]])

超出图层 durationFrames 的关键帧不会播放;渲染时会发出警告(animation_truncated),且 renderVideo({ qa: true }) 会报告此问题。

2. 动态排版(TextAnimator)

文本动画器在 GPU 字形实例化层级运行,可在字符、单词或行之间级联偏移:

import { Layer, LayerAnimation, TextAnimator } from "framefields";

// Letter-by-letter wave rise with perspective tilt and blur
const headlineNode = Layer.text("PRECISION MOTION", {
  fontSize: 96,
  fontFamily: "Inter",
  fill: "#FFFFFF",
  animators: [
    TextAnimator.waveRise({
      y: 40,
      rotationX: 45,
      opacity: 0,
      blur: 10,
      easing: "expo.in"
    })
  ]
}).animate(
  // Sweep the selection offset across the text string
  LayerAnimation.create().kineticSweep(-1, 1, 0, 24, "power2.inOut")
);

可用预设:TextAnimator.waveRise()、TextAnimator.wordPop()、TextAnimator.blurIn()、TextAnimator.flip3D()、TextAnimator.colorSweep()。

3. 3D 空间变换与摄像机运动

图层可以在 3D 透视空间中变换:

const card = Layer.box({
  width: 600,
  height: 400,
  background: "#181715",
  borderRadius: 24,
  transformStyle: "preserve-3d",
  perspective: 1200,
}).animate(
  LayerAnimation.create()
    .fromTo("rotateX", 25, 0, { start: 0, end: 30, ease: "expo.out" })
    .fromTo("rotateY", -35, 0, { start: 0, end: 30, ease: "expo.out" })
    .fromTo("translateZ", -200, 0, { start: 0, end: 30, ease: "expo.out" })
);

如需完整的 3D 摄像机布景,请使用 CameraAnimation:

import { CameraAnimation } from "framefields";

const camAnim = CameraAnimation.camera()
  .orbit({
    azimuth: { from: -45, to: 15 },
    elevation: { from: 10, to: 25 },
    radius: { from: 2000, to: 1400 },
    start: 0,
    end: 90,
    ease: "sine.inOut"
  })
  .rackFocus({
    fromDistance: 2000,
    toDistance: 1400,
    fromFStop: 1.8,
    toFStop: 4.0,
    start: 20,
    end: 60
  });
4. 程序化与音频响应驱动器

属性可以绑定到 Perlin 摆动、谐波弹簧或从音频提取的信号:

// Organic floating drift
const floatAnim = LayerAnimation.create()
  .wiggle("x", { frequency: 1.5, amplitude: 25 })
  .wiggle("y", { frequency: 1.2, amplitude: 20 });

// Physics spring overshoot
const springAnim = LayerAnimation.create()
  .spring("scale", { damping: 14, stiffness: 190, mass: 1 });

// Audio reactivity: pass the signal object straight in…
const kick = Signal.fromArray(kickEnvelope, fps); // or Signal.builder({ type: "custom", fn })
const beatAnim = LayerAnimation.create()
  .signal("scale", kick, { multiplier: 0.12, offset: 1.0, smoothing: 2 });

// …or register it once by name and bind the name (handy when many layers share it)
comp.addSignal("drum_stem_signal", kick);
const beatAnim2 = LayerAnimation.create()
  .signal("opacity", "drum_stem_signal", { multiplier: 0.5, offset: 0.5 });

.signal() / .colorSignal() 会在每一帧通过 value × multiplier + offset 驱动属性。信号对象会随其轨道携带,并自动注册到程序中(使用生成的 inline_signal_N ID,因此规范保持为纯 JSON);字符串则会在 comp.addSignal 名称中查找。其他任何值(数字、undefined)都会触发错误。信号按合成帧(ctx.frame)采样,而不是按图层的本地帧采样。


颜色、渐变与文本度量

每个颜色属性都接受十六进制、rgb()、hsl()、oklch()、oklab() 和 CSS 颜色名称。颜色关键帧会进行**感知均匀(OKLab)**混合,因此红色 → 绿色会经过黄色,而不是浑浊的颜色。Color 可用于构建颜色,无需手动编写数学运算;每个辅助函数都会返回十六进制字符串:

import { Color, Gradient } from "framefields";

Color.oklch(0.72, 0.16, 250);          // equal L = equal perceived brightness: balanced palettes
Color.mix("#ff5a1f", "#1f6bff", 0.5);  // perceptual mix
const wave = Color.ramp(["#3b82f6", "#8b5cf6", "#ec4899"]); wave(0.3);
Color.steps(["#0E0D0C", "#FF5A1F"], 5); // 5 swatches
Color.lighten(EMBER, 0.1); Color.alpha("#000", 0.4); Color.readableOn(bg);
Color.contrast(fg, bg);                 // WCAG ratio; ≥ 4.5 body text, ≥ 3 large

渐变是 CSS 字符串,可用于任何 background(方框、弹性布局、卡片;保留圆角)和任何形状的 fill。支持线性、径向和圆锥渐变,最多 8 个色标、硬色标(red 0 50%, blue 0),以及保留色相的 transparent 渐隐:

Layer.box({ background: "linear-gradient(135deg, #ff5a1f, #ffb800 60%, #7c3aed)" });
Layer.shape("circle", { fill: Gradient.radial(["#fff", "transparent"]) });
Layer.box({ background: Gradient.conic(["red", "yellow", "lime", "aqua", "blue", "magenta", "red"]) });

使用真实字体测量文本,不必猜测字宽(放置插入符、给单词加下划线、设置胶囊形元素的大小):

const m = FontManager.measure("Hello, world", { fontFamily: "Inter", fontSize: 64 });
m.width; m.height; m.lines; m.caretX(5); m.glyphs[0].x; // px, relative to the text box

组合动画

.animate() 会在节点已有动画的基础上叠加动画:可以多次调用,也可以传入多个动画。同一属性上的关键帧轨道会合并为一条排序后的轨道;同一帧上的关键帧以后出现的为准。分别编写行为,再将它们组合起来:

const rainIn = LayerAnimation.keys("y", [[0, -300], [d, -300], [d + 12, 0, "back.out(1.5)"]]);
const press  = LayerAnimation.keys("y", [[40, 0], [42, 6, "power2.in"], [47, 0, "power2.out"]]);
const glow   = LayerAnimation.keys("color", [[42, GLOW], [60, REST, "sine.inOut"]]);
key.animate(rainIn).animate(press, glow);   // or LayerAnimation.merge(rainIn, press, glow)
逐格节奏(“二拍”)

LayerAnimation.stepped(n) 会让图层的每个动画值保持 n 帧:2 即为“二拍”,这种卡顿感带有赛璐珞动画、定格动画和纸片剪纸的手工质感。它适用于图层的所有轨道(关键帧保留其缓动;信号和摆动也会逐格变化)。将逐格动画角色与平滑的镜头或背景搭配,即可呈现经典效果。

puppet.animate(walkCycle, bob, LayerAnimation.stepped(2));
形状变形

路径形状的 d 属性可动画化:为两个或更多 SVG 路径设置关键帧,它们就会发生变形。路径可以是任意形式(点数不同、曲线、弧线、多个子路径);系统会重新采样并对齐它们,避免变形时扭曲。缓动照常生效。

Layer.shape("path", { position: "absolute", x: 0, y: 0, width: 400, height: 400, d: BLOB_A, fillColor: "#3B82F6" })
  .animate(LayerAnimation.keys("d", [[0, BLOB_A], [24, STAR, "back.out(1.4)"], [48, HEART, "expo.inOut"]]));

若要让独立形状融合成流体效果,可将它们绘制到同一个 SVG 或粒子图层中,并添加 Gooey(模糊 + Alpha 阈值 = 元球)。


混合模式

任何图层都可以使用 blendMode,设置它与下方内容的混合方式。可将其用于辉光、纹理、漏光、颗粒、色彩叠加,以及叠加在视频素材上的文字。

模式用途
"normal"(默认)普通叠加。
"screen"、"lighter"(相加)、"color-dodge"、"lighten"光效:辉光、闪光、漏光、以黑色为背景拍摄的火焰/火花素材。
"multiply"、"color-burn"、"darken"阴影:纸张和做旧纹理、阴影、纸上的墨迹。
"overlay"、"soft-light"、"hard-light"对比纹理、胶片颗粒、保留图像明暗的色彩叠加。
"difference"、"exclusion"视频上反色的文字、故障风闪光。
"hue"、"saturation"、"color"、"luminosity"使用纯色图层重新着色视频素材("color" = 色调,保留明暗)。
"mask-in" / "mask-out"、"luma-in" / "luma-out"、"source-*"、"destination-*"、"xor"合成运算符:在此图层不透明/透明/明亮/暗淡的位置保留底图。制作显露效果时优先使用 withMatte。
Layer.video("assets/light-leak.mp4", { position: "absolute", x: 0, y: 0, width: W, height: H, fit: "cover", blendMode: "screen", opacity: 0.8 })
Layer.box({ position: "absolute", x: 0, y: 0, width: W, height: H, background: "#ff3d7f", blendMode: "color", opacity: 0.6 })   // duotone-ish tint

轨道遮罩(遮罩、擦除、显现)

node.withMatte(matteLayer, mode) 仅在遮罩不透明("alpha",默认值)、透明("alpha-inverted")、明亮("luma")或暗淡("luma-inverted")的位置显示 node。遮罩的布局与同级元素相同(同一父级;绝对定位),从节点的起始帧开始设定关键帧;如果遮罩没有 x/y/width/height,则覆盖节点的边界框。遮罩本身不会被绘制。在 3D 图层(或模型)上,遮罩会在 3D 渲染过程中以屏幕空间方式工作,因此该图层仍按深度排序,且被遮挡的部分不会挡住其后方的内容;带有自身 z 值的遮罩会像任何 3D 图层一样进行投影。

// Text revealed by a sliding bar
Layer.text("LAUNCH", { id: "title", position: "absolute", x: 560, y: 480, width: 800, height: 120, fontSize: 120 })
  .withMatte(
    Layer.box({ position: "absolute", x: 560, y: 480, width: 800, height: 120, background: "#fff" })
      .animate(LayerAnimation.keys("x", [[0, -240], [24, 560, "expo.out"]])),
  );

// Video inside type: the text is the matte
Layer.video("assets/surf.mp4", { position: "absolute", x: 0, y: 0, width: W, height: H, fit: "cover" })
  .withMatte(Layer.text("OCEAN", { position: "absolute", x: 0, y: 340, width: W, fontSize: 380, align: "center", fill: "#fff" }));

场景与转场

Layer.sequence 会将全帧场景首尾相接地排列,并在每对场景之间按转场时长重叠,同时为你写入场景的 startFrame / durationFrames(每个场景都从自己的第 0 帧开始设定关键帧)。转场是合并到场景中的普通关键帧和遮罩:

import { Layer, Transition } from "framefields";

const film = Layer.sequence([
  { scene: hero(), duration: beats(8) },
  { scene: features(), duration: beats(12), transition: Transition.push("left", 18) },
  { scene: proof(), duration: beats(8), transition: Transition.wipe("right", 16) },
  { scene: outro(), duration: beats(6), transition: Transition.iris(20) },
], { width: W, height: H });
comp.add(film);                       // film.totalFrames → composition length
转场效果
cut()硬切
crossfade(frames)新场景在旧场景上淡入
fadeThrough(frames)画面淡入背景色后再淡出
push(dir, frames, ease)新场景将旧场景推出画面
slide(dir, frames)新场景滑过旧场景(旧场景变暗)
zoom(frames)旧场景放大并淡出,新场景逐渐稳定
whip(dir, frames)快速带运动模糊的推出效果
wipe(dir, frames)硬边缘扫过画面(遮罩)
iris(frames, { at })圆形从某一点向外展开(遮罩)

3D 外观:环境、材质、阴影、泛光

场景不包含 Layer.environment 时,会保留经典的 Blinn-Phong 外观。添加一个环境后,整个 3D 渲染流程就会变为基于物理的渲染:采用线性光照的 GGX 着色,其中 roughness 和 metallic 的表现与 Blender 中相同;程序化摄影棚环境会为所有物体照明并产生反射;还支持曝光、色调映射,以及可选的雾效和泛光。

**打造出色产品图的最快方式:使用灯光组。**它会返回一个环境,以及布置好的主光/轮廓光(/补光),其中主光会投射柔和阴影:

import { Lighting } from "framefields";

comp.add(Lighting.studio({ target: [W / 2, H / 2, 0] }));   // also: soft, sunset, dramatic
comp.add(Lighting.dramatic({ target: [W / 2, H / 2, 0], bloom: true, fog: { color: BG, start: 2500, end: 6000 } }));
// knobs: intensity, exposure (stops), rotation (turns the rig), shadowSoftness, shadows: false

也可以手动设置:

comp.add([
  Layer.environment("studio", {        // presets: studio | soft | sunset | night | neutral
    exposure: 0.3,                     // stops
    toneMapping: "neutral",            // default; "agx" filmic, "aces" punchy, "none"
    rotation: 30,                      // turn the reflections
    fog: { color: BG, start: 2000, end: 5000 },   // surfaces fade into the background color
    bloom: true,                       // or { intensity: 0.3, threshold: 1.6, radius: 6 }
  }),
  Layer.directionalLight({ x: 400, y: -1200, z: -600, targetX: 960, targetY: 540, castShadow: true }),
]);

Layer3D.sphere({ radius: 90, color: "#e8e8e8", metallic: 1, roughness: 0.15 });  // chrome
Layer3D.roundedBox({ ..., color: "#ff5a1f", roughness: 0.45 });                  // satin plastic
Layer3D.cylinder({ ..., color: "#111", emissive: "#4da3ff", emissiveIntensity: 3 }); // neon tube
  • 默认色调映射 "neutral" 会保留原始品牌色:橙色仍是橙色。使用 "agx" 可获得电影感的高光过渡。
  • 只有远超白色的光才会产生泛光(自发光 ≳ 3、过亮的镜面反射),因此普通的亮面不会发光。提高 emissiveIntensity 可让物体发光。
  • **阴影:**每个场景只能有一个启用 castShadow 的平行光或聚光灯。网格默认会投射阴影(设置 castShadow: false 可关闭);平面、卡片等平坦图层只有在设置 castShadow: true 时才会投射阴影。所有物体都会接收阴影。
  • 雾效会精确淡化至 fog.color,因此请将其设为构图背景色,以实现无缝衔接的地平线。
  • **模型:**glTF 材质包含基础颜色、法线、金属度-粗糙度、遮蔽和自发光贴图;OBJ(.mtl 中的 map_Kd、norm)和 FBX 包含漫反射贴图和法线贴图,贴图会在模型所在目录中查找。纹理必须为 PNG、JPEG、WebP、GIF、TIFF 或 AVIF 格式:TGA、PSD、DDS 和 KTX2 会被跳过并显示警告(请转换格式)。Draco/meshopt 压缩的 glTF 文件无法加载:请重新导出,不使用网格压缩。顶点颜色(VOX、PLY、OFF、glTF COLOR_0)会与基础颜色相乘。
  • **模型朝向:**世界坐标系的 y 轴朝下,因此 y 轴朝上的模型(大多数 glTF、OBJ 和 FBX 文件)需要设置 rotateX: 180 才能正立。
  • emissive/emissiveIntensity/exposure 可以像任何属性一样制作动画(.keys("emissiveIntensity", …))。
  • **透明 3D 网格:**glTF 的 BLEND 材质和透明度降低的网格会在不透明场景项之后绘制,并根据每个网格边界框中心的位置由后向前排序,且不会写入深度。这只是对象级近似:相交或彼此重叠的网格不一定总能正确排序;半透明平坦图层和玻璃则使用单独的渲染路径。
  • **玻璃:**在任何 Layer3D 基元、挤出文本(Layer3D.extrudedText)或模型上设置 material: "glass" | "acrylic" | "frosted",即可折射屏幕上其后方的内容——视频、2D 图层、粒子——并产生彩虹色边缘(dispersion,默认值为 0.04)、弯折强度 thickness(px,默认值为 80)和 ior(1.5)。color 可为玻璃着色(边缘处颜色更深);frosted 会通过 roughness 添加模糊效果。玻璃不需要环境:它会自行绘制摄影棚反射,在深色调色效果下观感最佳;仅有玻璃的场景请跳过 Lighting.* 灯光组(它们的环境会冲淡玻璃效果)。玻璃只会显示它之前绘制的 2D 内容和不透明 3D 内容,不会显示其他玻璃。

物理(Physics.world2d / Physics.world3d)

适用于任何应该下落、弹跳、堆叠、摆动或碰撞的内容:文字或卡片受重力掉落、UI 元素翻滚堆叠、弹簧抖动、球在盒子里滚动、骨牌,以及由物理效果驱动的 3D 产品。若运动需要在精确帧到达精确设计姿势,则应使用关键帧。

世界会在构建合成时运行一次(Rapier,确定性构建:在每台机器上结果相同),并重放为逐帧信号,因此可以按任意顺序渲染帧。simulate() 会绑定每个刚体的图层(x、y、rotation;在 3D 中为 x/y/z、rotateX/Y/Z):

import { Layer, LayerAnimation, Physics } from "framefields";

const world = await Physics.world2d({ fps: FPS, frames: 150 }); // gravity 2400 px/s², y down
world.walls({ width: W, height: H - 160 });                       // floor + sides (ceiling: true to close)

const cards = words.map((word, i) => {
  const card = Layer.box({ position: "absolute", x: 140, y: -300 - i * 260, width: 560, height: 170, rotation: -8, borderRadius: 28, background: EMBER /* … */ });
  world.body(card, { restitution: 0.35, startFrame: i * 6 });   // staggered drop
  return card;
});
world.simulate();

// Every body exposes signals; bind them to anything (props, effect uniforms):
glow.animate(LayerAnimation.create().signal("opacity", world.impact, { multiplier: 1.5 })); // loudest hit
stage.animate(LayerAnimation.create().signal("y", world.impact, { multiplier: 14 }));       // camera shake
  • 刚体的形状来自图层,并带有数值型 x、y 和尺寸;图层的姿态(包括 rotation、scale、anchorX/Y)就是初始姿态。碰撞体会跟随图层:圆形是球体,borderRadius 会将矩形边角变圆,多边形/星形/路径使用其凸包,没有尺寸的文本会测量尺寸。可通过 shape: "box" | "circle" | "hull" 和 size: [w, h] 覆盖。普通的 { x, y, width, height } 会创建一个不可见障碍物。
  • 刚体选项: type: "dynamic" | "fixed" | "kinematic"、restitution(0.3)、friction(0.6)、density(1)、velocity: [vx, vy] px/s、angularVelocity °/s、linearDamping、angularDamping、gravityScale(0 表示漂浮)、lockRotation、ccd(用于速度极快的刚体)、sensor、startFrame(在此帧之前保持其姿态)、output: "signals" | "keyframes" | "none"。
  • 每个刚体的信号: x、y、rotation、centerX、centerY、vx、vy、speed、angularVelocity、sleeping、impact(每次出现新接触时跳变,1 ≈ 以 1500 px/s 撞击,随后在 impactDecay 帧内衰减)。world.impact 是每帧冲击最响的刚体。body.collisions / world.collisions 会列出首次接触 { frame, with, speed }:可在这些帧添加撞击音效或闪光。
  • 场景: world.ground({ y })、world.walls({ x, y, width, height, ceiling })。
  • 关节: world.pin(body, [x, y])(绕一个点摆动)、world.hinge(a, b, [x, y])、world.weld(a, b)、world.spring(a, bOrPoint, { frequency: 2, dampingRatio: 0.2, length })、world.rope(a, bOrPoint, { length })。点的单位是世界坐标 px。
  • 驱动模拟: world.force(body, { x, y })(px/s²,与质量无关)、world.impulse(body, frame, [vx, vy])(施加冲击)、运动学刚体的 follow: { x, y, rotation }。每项都接受数字、信号(音频、Signal.fromArray)或 (frame) => value:例如,让地板在受到冲击时弹跳。
  • 力场: world.field((s) => [ax, ay], { bodies }) 会在每个求解器步骤中调用,并传入刚体中心 s.x、s.y(px)、速度 s.vx、s.vy 和 s.frame;返回 px/s² 或不返回任何值。风、涡流、吸引子、流场,以及表演者(如下)。
  • 模拟中的表演者: const dancer = comp.vision("dancer.mp4") 可获取视频片段逐帧的姿态和轮廓,并按需分析(参见 framefields-effects)。让世界保持实时,使其随帧渲染逐步模拟,而不是一次性完成:用 world.live(comp) 代替 simulate()。每帧绘制前,都会计算该帧的视频视觉信息(或从缓存读取),然后世界推进到该帧——不会预先分析任何内容,预览会立即播放并逐步填充,导出结果也完全一致(模拟相同,顺序相同)。接着,使用 const person = dancer.person.place({ width: W, height: H, fit: "cover" }):
    • 将四肢作为运动学碰撞体,用于接住并击打物体:world.body({ x, y, width: 2 * r, height: 2 * r }, { type: "kinematic", shape: "circle", follow: { x: (f) => person.point("leftWrist", f).x - r, y: (f) => person.point("leftWrist", f).y - r } }),沿每侧肩膀 → 肘部 → 手腕放置几个球体;
    • 将轮廓(包括裙装)作为力场:person.sample(frame, x, y) 返回 alpha(内部 > 0.5)和指向内侧的梯度 gx、gy,因此 world.field((s) => { const m = person.sample(s.frame, s.x, s.y); … return [-m.gx * k, -m.gy * k]; }) 可让粒子和道具绕着移动的人体流动。
    • 读取操作是同步的,并且需要先分析对应帧:可在实时世界渲染期间读取,或先执行 await dancer.ready(frame)(例如 ready(0),围绕第一帧姿态布置物体)。实时世界无法预知未来,因此应根据较早的帧放置物体。
    • 在粒子上方叠加同一视频片段的视觉抠像图层(Layer.video(src).withVision({ mode: "matte" })),使粒子从表演者身后经过。
    • 实时世界只绑定信号(不使用 bakeKeyframes());await world.advanceTo(frame) 可在不渲染的情况下推进一帧(例如读取 collisions)。
  • 粒子: 对于云雾、尘埃、火花、流体和流场,请使用 Layer.particles(GPU,见下文),而不要使用数百个物理刚体。物理刚体只用于少量必须碰撞和堆叠的物体(卡片、字母、产品)。
  • 用关键帧代替信号: 用 world.bakeKeyframes({ tolerance: 0.25 }) 代替 simulate(),生成简单、精简的线性关键帧(可编辑的普通 JSON)。
  • 3D: Physics.world3d 接受 Layer3D.roundedBox / sphere / cylinder 和模型(网格的凸包);支持相同选项,但使用 3D 向量;world.hinge(a, b, at, axis)、world.walls({ width, height, depth })。
  • 时间: 信号使用合成时钟。在从帧 S 开始的场景中,将 startFrame: S 传给世界。在该帧之前以及超出 frames 的范围后,信号会保持首个/最后一个值。在调用 simulate()(或读取任何信号)之前,添加所有刚体、关节和力;之后世界会被锁定。
  • 手感: 在 1080p 画布上,2400 px/s² 的重力会带来敏捷的效果;pixelsPerMeter(100)用于缩放物体的重量感。对于高堆叠物或细长且速度快的刚体,请提高 substeps(4)。
  • 安装说明:物理功能使用可选依赖 @dimforge/rapier2d-deterministic-compat / rapier3d-deterministic-compat(安装 framefields 时会默认安装)。

GPU 粒子(Layer.particles)

数万个粒子通过计算着色器模拟,并通过一次调用绘制——火花、余烬、尘埃、雪、烟雾、墨迹、流场、光绘,以及表演者穿行其中的流体。粒子层是在合成 px 中的一个方框;其中的位置以图层 px 为单位。

comp.add(Layer.particles({
  width: W, height: H, max: 20000, seed: 7,
  emitter: { shape: "rect", x: 0, y: H * 0.6, width: W, height: 200, rate: 3000 },  // point | line | rect | circle | ring; radial
  lifetime: [4, 6], speed: [0, 40], direction: -90, spread: 360,
  gravity: 20, drag: 1.5,                                      // px/s², per second
  noise: { strength: 260, scale: 170, speed: 0.25 },          // curl-noise flow: smoke, ink, flow fields
  attractors: [{ x: W / 2, y: H / 2, strength: 900, radius: 200 }],   // negative strength repels
  push: { person, strength: 9000, sweep: 120000 },             // a performer's silhouette pushes them (comp.vision)
  floor: H - 60, bounce: 0.2, bounds: "none",                  // none | kill | bounce | wrap
  size: [10, 4], sizeJitter: 0.5, fade: [0.1, 0.3],
  shape: "circle",                                             // circle | soft (glow) | square | spark (streaks along motion)
  color: ["#ffd23f", "#ff5a1f", "#7a2e14"],                    // over life, or by speed with colorBySpeed: 600
  blend: "normal",                                             // add for glows and light painting
  prewarm: 3,                                                  // seconds simulated before frame 0: starts full
}));
  • emitter.x / emitter.y、rate、gravity、drag、noise.strength、吸引器的位置/强度和 push 强度可接受数值、信号(音频、物理 impact)或 (frame) => value,并在每帧读取:可实现随节拍迸发、阵阵风,以及跟随追踪手部的吸引器(person.point("rightWrist", f))。emitter.bursts: [{ frame, count }] 可添加一次性爆发效果。
  • 配方:光绘:使用加法混合的 "soft" 点发射器,其 x/y 跟随追踪的手腕((f) => person.point("rightWrist", f).x),生命周期约 1 s,速度较低,并沿手部轨迹分散粒子出生位置,让快速移动保持连贯;火花/喷泉:shape: "spark"、blend: "add"、重力、floor;烟雾/墨迹:shape: "soft"、低不透明度、noise、逐渐增大的尺寸;雪/尘埃:在画面上方使用 rect 发射器、较小的重力、noise、bounds: "wrap";旋涡:ring 发射器 + 吸引器 + colorBySpeed;舞者周围的流体:push: { person } + drag 2 + 轻柔的噪声,并在粒子层上方叠加片段的视觉抠像。
  • 系统会随帧渲染逐步运行,并每隔 24 帧为自身创建快照,因此向后拖动时间轴时会恢复快照,而不是从头重放;相同的 GPU 会生成相同的帧。

程序化 VFX(Layer.vfx)

通过单个着色器绘制的全帧程序化效果,无效果处透明。将其叠加在素材上方或下方,并设置 blendMode(光效通常使用 "screen")。

Layer.vfx({ effectType: "Aurora", width: W, height: H, colorStart: "#2bff88", colorEnd: "#7a3cff", density: 0.7, speed: 1 })

effectType:Smoke、Fire、Rain、Sparks、Snow、Dust、Lightning、Magic、LensFlare、Embers、EnergyBeam、Aurora。共享参数:density、scale、speed、intensity 和 seed(数值或信号),以及 colorStart / colorEnd。如需会对物理效果、表演者或吸引器做出反应的粒子,请使用 Layer.particles。


关键编写规则与注意事项

  1. 关键帧使用局部坐标: 关键帧严格相对于节点自身的时间轴窗口(0 = 节点出现)。切勿将关键帧帧数偏移节点父级的 startFrame。
  2. 坐标覆盖: 为 y 添加动画会覆盖节点布局中的 y。如果节点位于 y: 400,请使用 fromTo("y", 440, 400),而非 fromTo("y", 40, 0)。
  3. 边框不要使用字符串样式: 请使用 borderColor 和 borderWidth。CSS border: "1px solid red" 会被静默忽略。
  4. 必须注册字体: 在无头 WebGPU 中,系统字体不存在。每种字体都必须通过 FontManager.register() 注册,并列入 comp.fonts。表情符号也需要彩色表情符号字体:framefields 不附带此类字体,因此请使用 FontManager.registerEmojiFont("assets/fonts/NotoColorEmoji.ttf") 注册一个(例如 Noto Color Emoji 这类 CBDT/CBLC 位图字体)。否则导出时不会绘制表情符号。
  5. 帧数必须为整数: 关键帧、起始帧和时长都必须是整数。小数帧会导致架构验证错误。
  6. 相对坐标与绝对坐标: 如果提供 x 或 y,却未设置 position: "relative",节点会变成绝对定位图层,并从弹性布局流中移除。
  7. 音频图层时长: 每个 Layer.audio() 都必须设置 durationFrames,以防无头渲染时音频被截断。
  8. 预乘 Alpha: Alpha 着色器使用预乘 Alpha。自定义混合中切勿使用 "src-alpha" 因子。
  9. 渲染前先运行 lint: comp.lint() 会列出可能导致透明绘制的颜色/渐变、低对比度文本、画布外图层、无效的阴影设置,以及缺少环境时使用的 roughness/metallic。renderVideo({ qa: true }) 会包含这些检查结果。
  10. Lottie 与图像图层: Lottie 矢量动画请使用 Layer.lottie("file.json" | "file.lottie", { width, height, fit })。切勿将 .json 或 .lottie 传给 Layer.image();它只会解码栅格格式,传入矢量动画时会抛出异常或发出警告。
  11. AI 生成的媒体是资源,不是构建步骤: 使用 ai(来自 framefields)生成的图像、片段、音乐或语音,应由单独的脚本生成一次并保存到 assets/generated/,之后像加载其他文件一样加载。切勿在 buildFilm() 或场景函数中调用 ai:这会产生费用,并导致影片在每次渲染、预览和测试时发生变化。framefields 技能(第 6 节)介绍了密钥、脚本和模型目录。

评论

支持 Markdown