油画风格视频
你用代码绘画,就像画家用画笔涂抹一样:先决定画面(构图、明暗、调色板),然后从后向前放置真实的笔触,观察结果,再调整。 引擎将这些笔触转化为帧;没有任何滤镜叠加在渲染结果上——每一个标记都是你亲手放置的笔触。
该方法是从四个绘画演示(过程、松树、画意树木、日落云)以及测量13幅公有领域大师风景画中学习而来的。提炼出的经验在 references/ 中;工作代码在 engine/ 和 examples/ 中。
设置(每个项目一次)
cp -R /path/to/painterly-video/engine ./painter-engine
cd painter-engine
npm ci
node bin/paint.mjs still ../path/to/scene.js --t 8 # → PNG输出到场景文件旁边(stills/<scene>/)
将 /path/to/painterly-video 替换为包含此 SKILL.md 的文件夹(例如 ~/.claude/skills/painterly-video)。需要 Node ≥ 22.12,支持 WebGPU 的 Google Chrome(或设置 CHROME_PATH),ffmpeg 和 ffprobe。参考图分析还需要 Python 和 tools/requirements.txt。无需构建步骤或渲染 API 密钥;渲染在本地硬件上运行。新建场景时,复制 examples/ 中最接近的文件(sunset-river.js 天空/云/水面 · lone-pine.js 垂直构图,单一主体 · lake-path.js 树木/深度/路径 · sunset-study.js 暗云团带边缘光的戏剧性天空(对演示结构的分析) · mountain-hamlet.js 山峦、小屋、岩石、行走的人影、炊烟 · brush-chart.js 笔刷和颜料混合)。
工作流程——不要跳过观察步骤
- 简报。 主题、情绪、时间、画幅(16:9, 9:16, 1:1——任意
--size)、时长,以及时间如何分配:绘画呈现过程、活画阶段,或两者兼有(默认:约60%呈现,40%活画)。 - 有参考图时先研究参考图(
tools/study.py,参见references/composition.md)。它会报告明度团块、基调、色度、调色板和焦点——学习结构,绝不复制画面。 - 先构图再上色。 在场景文件的顶部用注释写出构图:地平线高度(绝不在中间)、焦点(位于三分之一线上,最强明度对比和最高色度所在处)、引导线、边框,以及明度计划——每个大形状一个明度值(0–1),分为三个不等的团块。
references/composition.md。 - 用颜料构建调色板,放置在明度计划上。
tone(mix({ ultramarine: 1, transYellowOxide: 1 }), 0.42)。除焦点强调色外,所有地方的色度都保持较低(大师作品:平均OKLab色度 0.02–0.06,高色度区域约占8%)。 - 从后向前绘制,使用主题绘制器(
paintSky,paintCumulus,paintHills,paintMountains,paintWater,paintTreeline,paintPine,paintFir,paintTree,paintGrass,paintRocks,paintHouse,paintGlow,paintMoon,walker,boat,smoke……)以及其他内容使用原始layer.scatter/stroke/path。每个阶段一个p.phase('name'),图层由远及近创建。references/api.md。 - 观察、批评、调整——至少两轮。 渲染静帧,打开PNG,像评价一幅画一样审视它:眯眼(三个明度团块能读出来吗?),焦点是否在计划的位置,是否有任何元素等距/等大/等饱和,是否有任何标记显得机械?在代码中修正原因。在自己的静帧上运行
study.py并与计划比较。这个循环是技能的核心;第一次渲染永远不会正确。 - 时间。 使用
p.timelapse()实现呈现效果,然后是运动:sway,drift,shimmer,p.wind,p.camera(按图层深度的视差效果),p.dynamic用于移动的物体。references/animation.md。 - 联系表,然后渲染。
still --n 12 --sheet→ 检查时间线的每个阶段;然后render→qa。references/pipeline.md。 - 诚实报告:自动检查了哪些内容,你查看了哪些内容,你没有检查哪些内容。
场景框架
import { Painting, mix, tone, mixOk, skyColor, paintSky, ridge, paintHills, paintGrass, key, ease } from 'painter';
export const meta = { duration: 16, fps: 30 };
export default function ({ aspect, seed }) { // aspect = 渲染的宽度/高度
const p = new Painting({ aspect, seed, ground: '#c9b89a' }); // 有色底,绝不用白色
p.duration = 16;
const A = aspect; // 场景单位:x 0…A, y 0(顶部)… 1(底部)
const skyTop = tone(mix({ ultramarine: 2, titaniumWhite: 2 }), 0.72), skyLow = tone(mix({ cadmiumOrange: 1, titaniumWhite: 3 }), 0.88);
const hillCol = tone(mix({ ultramarine: 1, burntUmber: 1 }), 0.3);
const hill = ridge({ y: 0.68, amp: 0.05, freq: 2, seed });
p.phase('sky');
paintSky(p.layer('sky', { depth: 0 }), { color: skyColor({ top: 0, bottom: 0.7, stops: [[0, skyTop], [1, skyLow]] }), bottom: 0.72 });
p.phase('land');
paintHills(p.layer('hill', { depth: 0.6 }), { top: hill, color: (x, y, k) => hillCol, size: 0.03 });
paintGrass(p.layer('grass', { depth: 1 }), { bounds: [0, 0.8, A, 1.05], dark: hillCol, light: tone(hillCol, 0.5), blade: 0.05 });
p.timelapse({ start: 0.3, end: 10 }); // 画面在10秒时完成
p.camera = (t) => ({ zoom: key(t, [[10, 1], [16, 1.07]], ease.inOut) });
p.wind = (t) => ({ strength: key(t, [[0, 0], [10, 0.15], [12, 1]]) });
return p;
}
使其看起来像油画的原则(简要列表)
- 明度做苦工,颜色得荣誉。 规划三个不等的明度团块;每张静帧都要眯眼测试。
- 有色底,绝不用白色。 选择底色来协调画面(绿色下用暖赭石,日落下用淡奶油色)。
- 先远后近。 远处=更浅、更冷、更灰、更软、更薄的颜料、更小的笔触。近处=最暗的暗部、最高的对比和色度、最厚的颜料、最大的笔触。使用
atmosphere(color, d, air)。 - 暗部先涂且薄,亮部后涂且厚。
thick0.1–0.3 用于阴影和远处,0.8–1 用于高光;高光小而少。 - 亮衬暗,暗衬亮。 用比底层颜色一个微小明度步长(±0.05)的笔触打破每个团块;使用
p.valueAt(x, y)/p.sample(x, y)了解底层颜色。 - 笔触跟随形体:围绕云块,沿斜坡,平铺在水面,沿草叶向上,沿树干向下。
- 混合绿色并使其沉稳(
earth(mix({ ultramarine: 1, cadmiumYellow: 1 }), 0.3));将纯色留给一个强调色。 - 变化一切:大小、间距、高度、间隔。相等=机械。奇数分组、重叠形状、一个主导。
- 消失与明确的边缘:形体转向或后退处柔和(
soft0.5+),焦点处清晰。 - 时间的纯函数。 场景由
seed决定;帧仅依赖于t。永远不要使用Math.random()或挂钟时间——使用layer.rand/rng(seed)。
容易出现的问题(构建过程中学到的)
- 白色吞噬混合色。 光谱混合器按亮度加权颜料;1:9 的颜色:白色混合几乎就是白色。混合色调用少量或不加白色,然后使用
tone(c, value)放置。用chroma(c, 0.5–0.8)削减刺眼的结果。 - 云朵像球上插棍子 → 底部平坦,多个小叶片(
cumulusShapelobes 40–60),仅朝向太阳的一侧有光,圆形柔软的轻拍,适中的thick。 - 松树像棕榈树 → 针叶是圆形簇状(短扇形笔触的爆发)栖息在树枝上,之间有天空空隙,底部暗,顶部亮;树枝低垂并向上延伸。
- 树枝呈伞骨状/黑色树干的树木 → 枝条必须结束在树冠内部;树干暗部值约为0.22,不是黑色,并随距离增加空气感(
atmosphere)。 - 整个区域使用饱和色(橙色地面,青绿色天空)→ 使其沉稳;将纯色作为几个小笔触散布。
- 路径看起来像河流 → 暖浅灰,笔触沿行进方向,阴影中只有冷紫色。
- 由短虚线组成的水面看起来像数字的 → 水面是相互融合的长水平拖拽,只在靠近观察者处有少数涟漪标记(
paintWater所做的);不要手动添加成排的虚线。 - 海面看起来像玻璃镜面 → 使用
paintWater({ chop: 1 })添加波浪背面和白色浪花;湖面和河流保持chop: 0。 - 灯/窗户光是一个平坦的色块 → 在暗背景上用
paintGlow,然后是小的炽热核心;一个长的柔和笔触不是发光。 - 光线像白色尖刺 → 少而宽,非常柔和,低不透明度的光束(
paintRays默认值)。 - 在浅色薄颜料上,画布纹理变成网格 → 保持
look.weave≤ 0.2。 - 山脉像鳞片墙或斑马纹 → 使用
peaks()构建天际线,保持远处(薄颜料,size≈ 0.02,窄明度范围)并让paintMountains将其分割成几个大的受光与投影面。 - 平坦的带状不是云层/山丘/林线 → 变化顶部边缘、内部值并留有空隙。
- 场景因模糊的浏览器错误而失败 → CLI首先进行语法检查;运行时错误会打印场景行。局部变量与颜色名冲突(
leaf,sky,hill)是c.map is not a function的常见原因。 - 读取过期的静帧:在打开PNG之前,始终检查命令输出是否有错误。
- WebGPU 和 WebCodecs 仅在安全源上存在——CLI服务于
http://127.0.0.1;永远不要通过file://打开场景。
参考文件
references/painting-lessons.md—— 每个演示教授的内容以及如何映射到引擎调用。references/composition.md—— 场景构图、明度计划、来自大师作品的数据、study.py。references/api.md—— Painting / layer / brush / colour / subject-painter 参考,编写新的主题绘制器。references/animation.md—— 呈现节奏、活画运动、相机和视差、动态图层、沸腾效果、音频。references/pipeline.md—— CLI、渲染设置、性能、QA、故障排除、交付物。