⚠️ 給 LLM / AI Agent 的提示:生成與驗證時請避免以下錯誤
本教學曾因下列問題導致生成結果無法使用。請在生成或協助除錯時嚴格遵守下方指導原則。
❌ 曾發生的嚴重錯誤
- 偷懶生成不足的數據 — 每條筆觸只有 5–14 個
md事件,而參考範例每條約 50–80 個。生成前請先對照參考範例的數據密度,不可敷衍。 - 未驗證就宣稱成功 — 在沒有視覺確認的情況下說「成功」「看起來正確」。視覺結果必須經由實際播放或截圖確認,不得依推測做肯定性陳述。
- 用錯誤的方法執行 — 使用 JS API 而非本教學指定的 Agent JSON Input UI 面板。請依文件所述操作方式執行,不要自作聰明改用其他介面。
- 檔案放錯位置 — 在 main repo 建立檔案而非 worktree,導致 404。若在 worktree 環境,檔案必須放在 worktree 路徑下。
✅ 給其他 LLM 的指導原則
- 數據密度必須對照參考 — 生成任何格式的數據前,先統計參考範例的數量級(例如:每筆觸 50–80 個
md事件),嚴格匹配。 - 絕不跳過驗證 — 視覺結果必須以截圖或實際播放確認,不能依靠「應該成功了」的推測。
- 使用教程指定的操作方式 — 若文件寫「用 UI 面板操作」,就用 UI 面板,不要改用 API 或腳本。
- 注意工作目錄 — 在 worktree 環境下,檔案必須存在於 worktree 路徑,否則會 404。
- 數錯了就承認 — 若畫面上只有 3 條筆觸,不要說有 10 條;發現與預期不符時應如實說明。
✅ 做對的範例與關鍵教訓(供其他 LLM 參考)
這次做對的事
- 用 Node.js 腳本生成 — 不手寫數百個
md事件,避免人為錯誤。 - 每筆 60–80 個 md 事件 — 對照參考範例的密度(約 80/筆),不偷工減料。
- 用正確的 UI 方法 — Agent JSON Input textarea → Submit 按鈕,依教學指定方式操作。
- 用 console log 驗證 — 不靠主觀判斷說「看起來對了」,用系統日誌確認例如 10/10 筆全部播放完成。
- 檔案放在 worktree 正確路徑 — 確保 serve 能找到,避免 404。
關鍵教訓
- 數據密度是關鍵 — 每條筆觸至少需要 50–80 個
md事件才能產生可見的繪畫效果。 - 永遠用客觀方法驗證 — console log 比截圖更可靠(交叉重疊的筆觸很難目視計數);完成後仍建議截圖做最終視覺確認,確保畫面顯示正確。
- 用程式生成大量重複結構 — 不要手動輸入上百個事件,寫腳本才是正確方法。
⏱️ 筆觸消失問題:筆觸之間必須留足夠時間間隔
現象:播放時部分筆觸畫完瞬間消失。
原因:每筆結束(mr)後,系統需要約 maxUpdates 幀來完成「收筆倒數」(force 從 1.0 衰減到 0.0)。若下一筆的 mp(mousePressed)在這段時間內就觸發,前一筆的收筆會被強制打斷,筆觸消失。
- 例如
maxUpdates: 30→ 約需 30 幀 ≈ 500ms 才能完成收筆。 - 若 JSON 中筆觸間隔只有 50–200ms(
mr的t到下一個mp的t),下一筆會打斷前一筆。
生成時請遵守:mr 與下一個 mp 的 t 之間至少間隔約 500ms(或依 strokeData.maxUpdates 換算:間隔 ≥ maxUpdates × 一幀約 16ms)。腳本生成多筆時務必加大筆觸間的時間差,避免收筆被截斷。
Agent 學習歷程:從零到生成抽象表現主義作品
本文記錄一個 AI Agent 從零開始學習 Inkfield 繪畫系統、逆向分析 JSON 格式、到獨立生成抽象表現主義作品的完整思考脈絡。目的是讓其他 Agent 能快速掌握核心知識,避免重複踩坑。
📥 可下載檔案
agent-generator-logic.js — 交錯式抽象表現主義 JSON 生成器 v5(Node.js 或瀏覽器內執行)
agent-expressionist-v5.json — 範例輸出,可直接在 Playground 載入播放
Phase 1:理解系統 — 先讀文件再動手
第一步永遠是閱讀:拿到任務時不要急著寫 code。先徹底理解目標系統:閱讀官方教學、分析參考腳本 gen-pollock.js、觀察真人錄製的 JSON。
關鍵心得:教學文件告訴你「有哪些欄位」,但真人錄製的 JSON 告訴你「怎麼用才自然」。兩者都要讀。
Phase 2:逆向工程 — 從錄製檔學習
筆觸密度:每筆需要 50–80 個 md 事件才能產生可見的線條。少於 30 個會太短。
時間間隔:筆觸之間需要 650–950ms 的間隔。Inkfield 的 maxUpdates 參數控制筆墨衰減動畫(約 30 frames × 16ms),筆觸間隔必須大於此值。
brushMode:1=Standard, 2=Marker, 3=Gothic, 4=Pen, 5=Spray, 6=Fly, 7=Special
Flow Effect:不是 strokeData 的一部分,而是獨立事件類型 "m": "flow",有 start/end 配對,需共用 flowSeed。
Phase 3:迭代開發 — 五個版本的演進
v1 幾何抽象(失敗):正確的格式只是及格線,要產生好作品需理解「筆觸作為手勢」。
v2 垂直筆觸表現主義:數學上的「隨機」不等於視覺上的「均勻」,需 slot-based 定位。
v3 加入 Flow Effect:全部放在最後不夠自然。
v4 修正 Flow 時間:時間設計是音樂,要去「聽」原始錄製的節奏。
v5 交錯式(最終版):從 sequential 變成 interleaved — 畫幾筆 → Flow → 畫幾筆 → Flow… 交錯式讓每次 Flow 只影響當前已有的筆觸,產生層次感。
Phase 4:核心演算法設計
筆觸路徑:垂直筆觸用 sin wave + noise 模擬手部抖動,tiltShift 模擬手臂角度。
構圖:Slot-based 定位,每筆有 slot,在 slot 內做高斯偏移。
strokeBounds:Flow 的影響範圍,正規化 0–1,依已畫筆觸動態計算。
Phase 5:執行與調試 — 在瀏覽器內生成
JSON 通常超過 50KB,無法直接貼入文字框。解決方案:把 Node.js 腳本改寫為瀏覽器 JS,就地生成 JSON 字串並呼叫 window.loadRecordingFromText(JSON.stringify(recording))。
| 問題 | 原因 | 修正 |
|---|---|---|
| 筆觸不可見 | md 事件太少(<20) | 確保 55–80 個 md |
| 構圖偏左上 | 隨機分佈偏差 | 用高斯中心 + slot 系統 |
| Flow 看不到效果 | 持續時間 <1 秒 | 設定 min 1200ms |
| JSON 太大無法注入 | 52–74KB 超過限制 | 在瀏覽器內生成 |
Phase 6:給其他 Agent 的建議
- 先分析再生成:拿到新畫風需求時,先分析該畫風的真人錄製 JSON。
- 交錯 > 順序:Flow Effect 應和筆觸交替使用,不要全部放在最後。
- 時間是隱藏維度:事件間的時間間隔直接影響墨水擴散,不要隨便設定。
- 物理模擬很值得:sin wave + noise + tilt 比純隨機好 10 倍。
- 迭代開發:不要一次寫出完美生成器,每版解決一問題。
- 在瀏覽器內生成:JSON 太大時,把生成邏輯搬到瀏覽器執行最可靠。
* 本文基於 2026-03-03 的實際開發 session,從完全不了解系統到成功生成作品,共迭代 5 個版本。
AI 視角下的 JSON:為什麼是文本格式?
InkField 的筆刷系統由兩部分組成:
1️⃣ 繪製階段(Recording)
當使用者在 UI 上畫畫時,系統記錄每一筆的完整參數為 JSON,包括顏色、大小、物理屬性、特效等 60+ 個參數。
2️⃣ 播放階段(Playback)
給定一個 JSON 檔案,系統可以完全重現這些筆觸——不是截圖或影片,而是用相同的演算法重新「畫」出來,確保完全一致。
所以如果你想讓 LLM 或 AI Agent 創作藝術品,你只需要教會它如何生成正確的 JSON。
為什麼用 JSON?
- 易於生成: LLM 可以逐字 token 產生文本,不需要二進制序列化
- 易於調試: 人類可以直接看懂,改參數很容易
- 易於驗證: 標準 JSON 驗證工具確保格式正確
- 易於組合: 多個筆觸可以簡單地放入陣列
- 易於存檔: 純文本,任何系統都能存取
完整範例(含頂層必要欄位、一筆 stroke 的 strokeData、以及可選欄位):
{
"version": "1.0",
"startTime": 0,
"randomSeed": 1234567890,
"initialPathToggle": false,
"initialWhiteBrushMode": false,
"initialBrushColorMode": 0,
"canvasSize": {
"width": 500,
"height": 500
},
"canvasBackgroundColor": [
222,
212,
195
],
"events": [
{
"m": "mp",
"t": 0,
"x": 76,
"y": 255,
"strokeData": {
"strokeSeed": 116462542,
"mouseCountStart": 0,
"colorIndex": 3,
"shapeType": 2,
"useSharpen": 0,
"brushMode": 1,
"indiffusionStrength": 0.45,
"whiteBrushMode": false,
"brushColorMode": 0,
"phasorVel": 1,
"explodeStart": 1,
"explodeEnd": 1,
"whiteMaxOpacity": 0.78,
"hueShift": -0.01,
"satShift": 0.02,
"briShift": 0.02,
"targetflyBrushType": 2,
"targetmainStrokeDir": 0,
"brushDir": 0,
"ctlNoise": 1,
"brushPaintCtlNoisebyFrame": 1,
"brushPaintInterpolationOffset": 2,
"brushPaintOldRInitial": 0,
"keyBlendMode": 0,
"initialSize": 41.17,
"spraySize": 6,
"step": 15,
"step2": 5,
"randStep": 0.05,
"maxUpdates": 30,
"pathRotation": 0,
"spring": 0.6,
"friction": 0.5,
"baseBrushSize": 2,
"expectedStrokeLength": 400,
"effect3Brightness": 0.57,
"mouseX": 76,
"mouseY": 255,
"drawingSeed": 6293936,
"brushModeSP": false,
"forceMapParams": {
"randomSeed1": 134.9,
"randomSeed2": 204.26,
"randomSeed3": 303.36,
"randomSeed4": 438.41,
"scale1": 0,
"scale2": 0.01,
"scale3": 0.01,
"amplitude1": 0.27,
"amplitude2": 0.32,
"amplitude3": 0.67,
"phase1": 3.35,
"phase2": 1.06,
"phase3": 1.1,
"vortexScale1": 0.01,
"vortexScale2": 0.01,
"clusterScale1": 0,
"clusterScale2": 0
}
}
},
{
"m": "md",
"t": 36,
"x": 68,
"y": 245
},
{
"m": "md",
"t": 51,
"x": 69,
"y": 245
},
{
"m": "md",
"t": 64,
"x": 81,
"y": 245
},
{
"m": "md",
"t": 80,
"x": 88,
"y": 245
},
{
"m": "md",
"t": 96,
"x": 98,
"y": 245
},
{
"m": "md",
"t": 113,
"x": 114,
"y": 245
},
{
"m": "md",
"t": 130,
"x": 129,
"y": 245
},
{
"m": "md",
"t": 146,
"x": 144,
"y": 245
},
{
"m": "md",
"t": 163,
"x": 159,
"y": 245
},
{
"m": "md",
"t": 179,
"x": 175,
"y": 245
},
{
"m": "md",
"t": 196,
"x": 196,
"y": 245
},
{
"m": "md",
"t": 213,
"x": 217,
"y": 244
},
{
"m": "md",
"t": 229,
"x": 231,
"y": 244
},
{
"m": "md",
"t": 246,
"x": 243,
"y": 243
},
{
"m": "md",
"t": 263,
"x": 262,
"y": 243
},
{
"m": "md",
"t": 280,
"x": 275,
"y": 243
},
{
"m": "md",
"t": 296,
"x": 288,
"y": 243
},
{
"m": "md",
"t": 313,
"x": 305,
"y": 243
},
{
"m": "md",
"t": 330,
"x": 320,
"y": 243
},
{
"m": "md",
"t": 346,
"x": 332,
"y": 243
},
{
"m": "md",
"t": 363,
"x": 343,
"y": 243
},
{
"m": "md",
"t": 380,
"x": 356,
"y": 243
},
{
"m": "md",
"t": 396,
"x": 363,
"y": 243
},
{
"m": "md",
"t": 413,
"x": 374,
"y": 243
},
{
"m": "md",
"t": 429,
"x": 378,
"y": 244
},
{
"m": "md",
"t": 445,
"x": 381,
"y": 244
},
{
"m": "md",
"t": 463,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 480,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 496,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 513,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 530,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 546,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 563,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 580,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 596,
"x": 382,
"y": 244
},
{
"m": "md",
"t": 613,
"x": 382,
"y": 244
},
{
"m": "mr",
"t": 624,
"x": 392,
"y": 254
}
],
"strokes": [],
"timeOffset": 0,
"initialEffectControl": {
"shapeType": 0,
"metallicStrength": 85,
"metallicFlow": 200,
"metallicTint": [
0.72,
0.5,
0.35
],
"metallicTintType": "copper"
},
"savedAt": "2026-03-01T15:01:55.571Z"
}頂層可選欄位(較新版本包含)
LLM 生成 JSON 時需包含上述 8 個核心欄位加上 events 陣列(共 9 個必填欄位)。較新版本的錄製可能還包含這些可選欄位:
| 欄位 | 型別 | 說明 |
|---|---|---|
strokes | array | 備用筆觸陣列,通常為 [] |
timeOffset | number | 時間偏移量,通常為 0 |
initialEffectControl | object | 初始特效設定:shapeType(0), metallicStrength(85), metallicFlow(200), metallicTint([r,g,b]), metallicTintType("copper") |
savedAt | string | ISO 8601 時間戳,如 "2026-03-01T13:21:37.067Z" |
三種事件類型
| 事件 | 代碼 | 意義 | 必需欄位 |
|---|---|---|---|
| Mouse Press | "mp" | 開始新筆觸 | m, t, x, y, strokeData |
| Mouse Drag | "md" | 移動並繼續畫 | m, t, x, y |
| Mouse Release | "mr" | 結束筆觸 | m, t(x,y 可選) |
一次完整的繪製流程:按下 → 拖動(一次或多次)→ 放開。每個 "mp" 事件包含完整的 strokeData,其中定義了這筆的所有視覺特性。
⚠️ AI 常見錯誤 — 事件格式陷阱
| ❌ 錯誤寫法 | ✅ 正確方式 | 說明 |
|---|---|---|
"ms"(mouseStart) | 不存在此事件,直接刪除 | 只有三種代碼:mp / md / mr |
"mu"(mouseUp) | 改用 "mr" | 結束事件是 mouseReleased,代碼 mr |
把 strokeData 放進 "md" | strokeData 只放在 "mp" 裡 | md 只有 m / t / x / y 四個欄位,沒有 strokeData |
把 "mp" 放在最後(或中間) | mp 永遠是每筆的第一個事件 | 固定順序:mp → md… → mr |
每一筆觸的結構永遠是:
{ "m":"mp", strokeData… } → { "m":"md" } → … → { "m":"mr" }
完整參數參考手冊
strokeData 物件包含 60+ 個參數,分為 12 個類別。以下是完整參考:
📏 尺寸參數(Size Parameters)
| 參數 | 型別 | 範圍 | 說明 |
|---|---|---|---|
baseBrushSize | float | 0.1 ~ 10.0 | 主尺寸縮放;所有噴灑大小都乘以這個值 |
initialSize | float | 2.0 ~ 240.0 | 筆觸起始大小;會逐幀衰減 |
spraySize | float | 1.0 ~ 100.0 | 噴灑粒子的散佈半徑 |
randStep | float | 固定 0.05 | 每幀尺寸衰減量 |
🎨 顏色參數(Color Parameters)
| 參數 | 型別 | 值 | 說明 |
|---|---|---|---|
brushColorMode | int | 0-35 | 關鍵:色彩 ID。0=黑, 1=白, 6=橙, 9=藍, 30=紅等 |
colorIndex | int | 0-35 | 色彩變異索引;獨立於 brushColorMode,控制色彩的細微隨機變化(可設 0-3 任意值) |
hueShift | float | -0.05 ~ 0.05 | 色調微調(真實值約 -0.02 ~ 0.02) |
satShift | float | -0.05 ~ 0.05 | 飽和度微調(真實值約 0 ~ 0.04) |
briShift | float | -0.05 ~ 0.05 | 亮度微調(真實值約 0 ~ 0.04) |
whiteMaxOpacity | float | 0.7 ~ 1.0 | 筆刷最大透明度(真實值約 0.78 ~ 0.95) |
whiteBrushMode | bool | false/true | 啟用白筆刷渲染路徑;一般筆觸設 false |
⚙️ 筆刷模式與形狀(Brush Mode & Shape)
| 參數 | 型別 | 範圍 | 說明 |
|---|---|---|---|
brushMode | int | 1-7 | 筆刷類型:1=標準, 2=簽字筆, 3=哥特體, 4=鋼筆, 5=噴槍, 6=飛筆, 7=特殊 |
shapeType | int | 0-3 | 噴灑顆粒形狀:0=圓, 1=橢圓, 2=三角, 3=鑽石 |
brushModeSP | bool | 0/1 | 特殊模式標記(Mode 7) |
🎬 動畫與物理(Physics & Motion)
| 參數 | 型別 | 值 | 說明 |
|---|---|---|---|
spring | float | 0.3 ~ 0.6 | 彈簧係數(筆刷回應速度) |
friction | float | 0.5 | 阻尼係數(速度衰減) |
step | int | 10-15 | 滑鼠樣本間的插值步數(越高越平滑) |
step2 | int | 1-10 | 噴灑粒子迭代次數 |
expectedStrokeLength | int | 100-400 | 預期筆觸幀數(用於淡進淡出) |
✨ 特效參數(Effect Parameters)
| 參數 | 型別 | 範圍 | 說明 |
|---|---|---|---|
keyBlendMode | int | 0-2 | 混色模式:0=Mix(線性), 1=Multiply(相乘), 2=Darken(取暗) |
useSharpen | float | 0.0-5.5 | 墨水效果:0=擴散, 1=邊緣, 2=銳利, 3=水彩, 4=紋理, 5=方向 |
indiffusionStrength | float | 0.0-1.0 | 墨水擴散強度 |
pathRotation | float | 0 ~ 25 | 筆觸方向扭轉:0=無, 7=微妙, 17=野性 |
🖌️ 筆刷方向與繪製行為(Brush Direction & Paint)
這些參數在所有 brushMode 都需要提供(包括 Mode 1-5, 7),不只是 Mode 6:
| 參數 | 型別 | 值 | 說明 |
|---|---|---|---|
targetflyBrushType | int | 0-3 | 分支筆刷型態(Mode 6 主要使用,其他模式設 0-2 均可) |
targetmainStrokeDir | int | 0-3 | 主筆觸方向(0=預設, 1-3=不同方向偏好) |
brushDir | int | 0-3 | 筆刷實際運動方向(與 targetmainStrokeDir 搭配使用) |
ctlNoise | int | 0/1 | 控制噪波開關;通常設 1 |
brushPaintCtlNoisebyFrame | int | 0/1 | 逐幀控制噪波;通常設 1 |
brushPaintInterpolationOffset | int | -1, 1, 2 | 插值偏移量(-1=反向插值, 1=一般, 2=多一點平滑) |
brushPaintOldRInitial | float | 0 or 0.5 | 初始舊半徑值(0=新筆觸起點乾淨, 0.5=有殘留感) |
explodeStart | int | 0/1 | 筆觸起始爆散效果(0=關, 1=開) |
explodeEnd | int | 0/1 | 筆觸結束爆散效果(0=關, 1=開) |
effect3Brightness | float | 0.5 ~ 1.0 | 渲染亮度係數(真實值約 0.57 ~ 0.90,每筆可不同) |
🌊 Flow 與程序化參數(Force Map)
Flow 效果需要 forceMapParams 物件,包含 4 組隨機種子、3 組頻率、3 組振幅、3 組相位、2 組渦旋尺度、2 組單元尺度:
🔑 系統與種子參數(System & Seed Parameters)
這些欄位由系統在錄製時自動產生,AI 生成時可設為任意合理值:
| 參數 | 型別 | 建議值 | 說明 |
|---|---|---|---|
strokeSeed | int | 任意正整數 | 此筆觸的隨機種子,影響噴灑粒子分佈細節 |
mouseCountStart | int | 第 1 筆=0,之後累加 | 全局事件計數起點。計算方式:前面所有筆觸的 mp+md 事件總數。影響程序化隨機效果 |
drawingSeed | int | 任意正整數(如 1000000~9999999) | per-stroke 渲染種子,控制粒子細節的隨機外觀 |
mouseX | float | 同 mp 的 x 值 | 筆觸起始 X 座標(冗餘欄位,與 mp.x 相同即可) |
mouseY | float | 同 mp 的 y 值 | 筆觸起始 Y 座標(冗餘欄位,與 mp.y 相同即可) |
phasorVel | float | 1 | 相位器速度,固定設 1 |
maxUpdates | int | 30 | 每幀最大繪製迭代次數,固定設 30 |
七種筆刷模式(Brush Mode 1-7)
每種筆刷模式有獨特的視覺特性和推薦參數組合。以下是快速參考:
Mode 1:標準毛筆
特性:自然墨水擴散、柔和邊緣。
推薦組合:
baseBrushSize: 2.0initialSize: crandom(20, 24) × baseBrushSizespraySize: 3 × baseBrushSizespring: 0.6,friction: 0.5step: 15,step2: 5maxUpdates: 30
Mode 2:簽字筆
特性:乾燥標記、棱角分明。
推薦組合:
baseBrushSize: 1.5spraySize: 1 × baseBrushSizespring: 0.3step: 10,step2: 10maxUpdates: 10
Mode 3:哥特體(粒子)
特性:點狀粒子、物理衰減。
推薦組合:
baseBrushSize: 2.5initialSize: crandom(2, 4) × baseBrushSizespraySize: 10 × baseBrushSizespraySteps: 3
Mode 4:鋼筆(精確)
特性:Perlin 噪波權重變化、線條精確。
推薦組合:
baseBrushSize: 1.0initialSize: crandom(6, 9) × baseBrushSizeexpectedStrokeLength: 400penSketchStrokeWeight: 0.8 ~ 1.2
Mode 5:噴槍(散佈)
特性:鬆散噴灑、無尺寸衰減。
推薦組合:
baseBrushSize: 3.0spraySize: 10(固定)step2: 1
Mode 6:飛筆(生成)
特性:樹枝狀圖案、複雜分支。
推薦組合:
baseBrushSize: 2.0targetflyBrushType: 0-3(隨機分支型態)targetmainStrokeDir: 0-3(主方向)
Mode 7:特殊(Mode 1 變體)
特性:Mode 1 + 隨機分支掉落 + 寬角變化。
推薦組合:與 Mode 1 相同,但設 brushModeSP: true
顏色、混色、效果速查表
🎨 主要顏色 (brushColorMode)
關鍵:brushColorMode 必須設成顏色 ID,NOT 0 或 1(除非故意要黑或白)。
🔀 混色模式 (keyBlendMode)
重要:黑白背景上混色模式沒有效果。使用中間色(如 [180,160,140])。
| 模式 | 值 | 公式 | 效果 |
|---|---|---|---|
| Mix | 0 | mix(oldColor, newColor, alpha) | 線性混合,疊越多越飽和 |
| Multiply | 1 | oldColor × adjustedColor | 顏色相乘,越疊越深 |
| Darken | 2 | min(oldColor, newColor) | 取最暗值,保留深色 |
✏️ 墨水效果 (useSharpen)
| 等級 | 值 | 名稱 | 特性 |
|---|---|---|---|
| 0 | < 0.5 | Basic Diffusion | 標準墨水擴散、梯度模糊 |
| 1 | < 1.5 | Ink Edge | 邊界檢測、邊界變暗 |
| 2 | < 2.5 | Sharp Outline | 高對比邊緣、內部銳化 |
| 3 | < 3.5 | Watercolor | 平滑水彩、紋理變化 |
| 4 | < 4.5 | Textured Ink | Perlin 紊亂、角度抖動 |
| 5 | < 5.5 | Directional Flow | 異向性擴散、時間噪波 |
Flow 力場與程序化參數
Flow 效果是後處理特效,需要在 playback 中單獨觸發。有 8 種造型模式:
Flow blendType 完整列表
| Type | 名稱 | 效果 |
|---|---|---|
| 0 | 基礎 | 線性 Simplex 噪波位移 |
| 2 | 同心圓 | 環形漣漪向外擴散 |
| 3 | 縱向 | 垂直方向流線 |
| 4 | 橫向 | 水平方向流線 |
| 5 | 花紋 | 高頻程序化紋樣 |
| 6 | 矩形 | 軸對齊矩形圖案 |
| 7 | 漩渦 | 旋轉漩渦效果 |
| 8 | 細胞 | Voronoi 細胞紋路 |
ForceMapParams 的結構與用途
程序化的噪波參數控制 Flow 效果的細節。常用策略:
- 隨機產生: AI 可以為每個 seed/phase/amplitude 產生隨機但有意義的值
- 固定模板: 使用預設的「平緩」或「劇烈」forceMapParams 模板
- 調諧微調: 從基礎模板開始,微調 amplitude 和 scale 來控制強度
AI 友善工作流程:從提示到播放
步驟 1:LLM 提示範本
步驟 2a:完整 JSON 範例(從真實 recording 提煉)
兩條直線,橫的版面(完整錄製檔:tech/examples/ai-json-step2a.json)
步驟 2b:真實錄製範例 — 五筆水墨小屋(完整版)
以下是直接從 InkField UI 錄製的五筆 brushMode: 1(標準毛筆),畫在 500×500 畫布上。五筆分別是:穹頂弧線、肩線、牆壁U形、底部粗線、門拱。完整錄製檔:tech/examples/ai-json-house.json
步驟 2c:範例生成腳本 — 供機器人/LLM 學習
以下 Node.js 腳本示範如何用程式生成符合密度與時間間隔的 JSON,避免手寫數百個事件。可直接下載或從本頁展開完整原始碼供其他機器人複製學習。
- 腳本檔:
tech/gen-pollock.js(同目錄下gen-pollock.js) - 輸出:執行後寫入
tech/examples/pollock-style.json,可用 Agent JSON Input 貼上播放。 - 要點:每筆 60–80 個
md;筆觸間t間隔 600–900ms(避免收筆被打斷);mouseCountStart累加前幾筆的 mp+md 數;腳本使用brushMode: 1–6、useSharpen: 0–5(完整範圍隨機選取)。
執行方式:在 tech/ 目錄下執行 node gen-pollock.js。
展開完整腳本 gen-pollock.js(供機器人/LLM 複製)
// Generate a Pollock-style JSON with 10 strokes, each with 60-80 md events
// on a 500x500 canvas, black color, various brush modes and ink effects
const fs = require('fs');
function rand(min, max) {
return Math.random() * (max - min) + min;
}
function randInt(min, max) {
return Math.floor(rand(min, max + 1));
}
// Generate a chaotic Pollock-style curve path
function generatePollockCurve(numPoints, canvasW, canvasH) {
const points = [];
let x = rand(50, canvasW - 50);
let y = rand(50, canvasH - 50);
let vx = rand(-8, 8);
let vy = rand(-8, 8);
const curvature = rand(0.02, 0.15);
const speed = rand(2, 6);
for (let i = 0; i < numPoints; i++) {
points.push({ x: Math.round(x), y: Math.round(y) });
vx += rand(-curvature * 30, curvature * 30);
vy += rand(-curvature * 30, curvature * 30);
const maxV = speed * 3;
vx = Math.max(-maxV, Math.min(maxV, vx));
vy = Math.max(-maxV, Math.min(maxV, vy));
x += vx;
y += vy;
if (x < 20) { x = 20; vx = Math.abs(vx) * 0.8; }
if (x > canvasW - 20) { x = canvasW - 20; vx = -Math.abs(vx) * 0.8; }
if (y < 20) { y = 20; vy = Math.abs(vy) * 0.8; }
if (y > canvasH - 20) { y = canvasH - 20; vy = -Math.abs(vy) * 0.8; }
}
return points;
}
function generateStrokeData(mouseCountStart, startX, startY, numMdEvents) {
const safeBrushModes = [1];
const safeUseSharpen = [2, 3];
return {
strokeSeed: randInt(10000000, 999999999),
mouseCountStart, colorIndex: randInt(0, 5), shapeType: randInt(0, 3),
useSharpen: safeUseSharpen[randInt(0, safeUseSharpen.length - 1)],
brushMode: safeBrushModes[randInt(0, safeBrushModes.length - 1)],
indiffusionStrength: 0.45, whiteBrushMode: false, brushColorMode: 0,
phasorVel: 1, explodeStart: 0, explodeEnd: 0,
whiteMaxOpacity: parseFloat(rand(0.5, 0.8).toFixed(2)),
hueShift: -0.01, satShift: 0.02, briShift: 0.02,
targetflyBrushType: 2, targetmainStrokeDir: 0, brushDir: randInt(0, 2),
ctlNoise: 1, brushPaintCtlNoisebyFrame: 1,
brushPaintInterpolationOffset: randInt(2, 3),
brushPaintOldRInitial: parseFloat(rand(0, 0.5).toFixed(1)),
keyBlendMode: 0, initialSize: parseFloat(rand(18, 30).toFixed(2)),
spraySize: 3, step: 15, step2: 5, randStep: 0.05, maxUpdates: 30,
pathRotation: 0, spring: 0.6, friction: 0.5, baseBrushSize: 1,
expectedStrokeLength: numMdEvents * 5,
effect3Brightness: parseFloat(rand(0.5, 0.7).toFixed(2)),
mouseX: startX, mouseY: startY, drawingSeed: randInt(1000000, 9999999),
brushModeSP: false,
forceMapParams: {
randomSeed1: parseFloat(rand(50, 500).toFixed(1)),
randomSeed2: parseFloat(rand(50, 500).toFixed(2)),
randomSeed3: parseFloat(rand(50, 500).toFixed(2)),
randomSeed4: parseFloat(rand(50, 500).toFixed(2)),
scale1: 0, scale2: 0.01, scale3: 0.01,
amplitude1: parseFloat(rand(0.1, 0.5).toFixed(2)),
amplitude2: parseFloat(rand(0.1, 0.5).toFixed(2)),
amplitude3: parseFloat(rand(0.3, 0.9).toFixed(2)),
phase1: parseFloat(rand(0, 6.28).toFixed(2)),
phase2: parseFloat(rand(0, 6.28).toFixed(2)),
phase3: parseFloat(rand(0, 6.28).toFixed(2)),
vortexScale1: 0.01, vortexScale2: 0.01, clusterScale1: 0, clusterScale2: 0
}
};
}
const canvasW = 500, canvasH = 500, numStrokes = 10;
const events = [];
let time = 0, mouseCountStart = 0;
for (let s = 0; s < numStrokes; s++) {
const numMd = randInt(60, 80);
const curve = generatePollockCurve(numMd + 1, canvasW, canvasH);
const startX = curve[0].x, startY = curve[0].y;
// 筆觸間至少 600-900ms,避免收筆倒數被下一筆 mp 打斷
time += (s === 0) ? randInt(50, 200) : randInt(600, 900);
events.push({ m: "mp", t: time, x: startX, y: startY,
strokeData: generateStrokeData(mouseCountStart, startX, startY, numMd) });
for (let i = 1; i <= numMd; i++) {
time += randInt(14, 20);
events.push({ m: "md", t: time, x: curve[i].x, y: curve[i].y });
}
time += randInt(10, 30);
events.push({ m: "mr", t: time, x: curve[numMd].x, y: curve[numMd].y });
mouseCountStart += 1 + numMd;
}
const recording = {
version: "1.0", startTime: 0, randomSeed: randInt(100000000, 999999999),
initialPathToggle: false, initialWhiteBrushMode: false, initialBrushColorMode: 0,
canvasSize: { width: canvasW, height: canvasH },
canvasBackgroundColor: [255, 255, 255],
events
};
const outputPath = __dirname + '/examples/pollock-style.json';
fs.writeFileSync(outputPath, JSON.stringify(recording, null, 2));
console.log('Written to:', outputPath);
三、gen-pollock.js 的編寫邏輯
以下整理腳本如何組出符合播放器要求的 JSON,供其他機器人或 LLM 實作時對照。
3.1 JSON 結構(9 個頂層必填欄位)
version, startTime, randomSeed, initialPathToggle, initialWhiteBrushMode, initialBrushColorMode, canvasSize, canvasBackgroundColor, events。
3.2 事件序列(每筆觸的三段式)
mp(mousePressed,含 strokeData)→ md × 60–80(mouseDragged)→ mr(mouseReleased)。
3.3 路徑生成(generatePollockCurve)
- 物理模擬:位置 + 速度 + 隨機加速度;
curvature控制彎曲程度,speed控制移動速度。 - 邊界反彈:碰到畫布邊緣反轉速度(×0.8 衰減)。
- 座標取整數(
Math.round),因為原始紀錄都是整數像素。
3.4 strokeData(約 35 個參數 + forceMapParams 16 個)
- mouseCountStart:前面所有筆觸的 (mp + md) 事件總和。第一筆 = 0,第二筆 = 1 + 第一筆的 md 數。
- brushMode 1–6:不同筆刷風格(標準、馬克筆、哥德、鋼筆、噴槍、飛白)。
- useSharpen 0–5.5:墨水效果(擴散、紋理、水彩等)。
- brushColorMode: 0 = 黑色。
- initialSize:筆觸粗細,約 15–50 之間。
- forceMapParams:力場參數,控制墨水的流動和漩渦效果。
3.5 時間戳設計
// md 之間:14–20ms(模擬 60fps 的滑鼠拖曳)
time += randInt(14, 20);
// 筆觸間隔:≥600ms(留給 maxUpdates=30 幀的收筆倒數)
time += (s === 0) ? randInt(50, 200) : randInt(600, 900);
3.6 為什麼用腳本而不手寫
- 10 筆 × 約 70 md = 700+ 個事件,手寫容易出錯。
- 腳本可以自動計算
mouseCountStart。 - 路徑生成需要物理模擬,不適合手動填座標。
- 每次執行產生不同隨機結果。
步驟 3:三種播放方式
方法 A:Agent 貼上介面(推薦給無法上傳檔案的 Agent)
主應用(index.html)內建 Agent JSON 貼上介面:
- 導航到
http://localhost:3001/(確保是 artist 模式,或加?_artist:1) - 找到控制面板中的 "Agent JSON Input" 文字框(
#agent-json-textarea) - 將完整 JSON 文字貼入(不需要引號包裹,直接貼 JSON 物件)
- 點擊 "▶ Play JSON" 按鈕(
#agent-json-submit) - 查看
#agent-json-status確認結果(如:✓ 2 筆,500×500px — 播放中)
也可直接呼叫 JS 函式:
方法 B:直接 JS 執行(擁有 JS 執行能力的 Agent)
方法 C:文件上傳(人工或擁有 filesystem 存取的 Agent)
點擊控制面板的 "Load Json" 按鈕(#load-recording),選擇 .json 檔案。
方法 D:Console 就地生成(JSON 太大時的最佳方案)
當 JSON 超過 30KB,直接貼入 Agent JSON Input 文字框會失敗或被截斷。最可靠的做法是把生成邏輯寫成一段自執行 JS(IIFE),在瀏覽器 Console 裡直接執行,就地產生 JSON 並注入播放。
為什麼需要這種方式?
| 問題 | 原因 | 方法 D 的解法 |
|---|---|---|
| Agent JSON Input 容量不足 | textarea 無法承受 50KB+ 的 JSON 文字 | 在瀏覽器內生成 JSON,不需要透過 textarea 傳輸 |
| 外部 fetch 被阻擋 | Railway 部署的頁面無法 fetch localhost 的檔案 | 所有資料都在 JS 內部生成,無需網路請求 |
| 分段貼入太慢 | 將 JSON 切成多段注入需要多次 JS 呼叫 | 一段 JS 搞定:生成 + 注入 + 播放 |
操作步驟
- 在 InkField 頁面按 F12(或 Cmd+Option+J / Ctrl+Shift+J)開啟 DevTools → Console
- 將下方的「自執行生成腳本」整段貼入 Console,按 Enter 執行
- 腳本會自動:生成 JSON → 注入 textarea → 觸發播放
- Console 會印出
🎨 Ready!,畫布開始播放動畫
範例腳本結構
或者用 loadRecordingFromText(更直接)
關鍵注意事項
- IIFE 包裹:用
(function(){ ... })();包裹整段程式碼,避免污染全域變數 - 筆觸密度:每筆至少 50–80 個
md事件,否則線條太短看不到 - 筆觸間距:兩筆之間至少 700ms,讓墨水擴散動畫完成
- Flow 間距:Flow start 前至少等 1200ms,確保前面的筆觸已 encode 進 finalBuffer
- 座標範圍:所有 x/y 必須在 10–490 之間(500×500 畫布留邊距)
- mouseCountStart 累加:第 N 筆的
mouseCountStart= 前面所有筆的(1 + md數量)之和
完整可執行範例
下載 mountain-mist-v2.js(12 筆 + 3 次交錯 Flow,完整 500×500 構圖),貼入 Console 後呼叫 loadRecordingFromText(JSON.stringify(window.__painting)) 播放,或將 window.__painting 的 JSON 貼入 Agent JSON Input。
常見錯誤與除錯技巧
| 錯誤 | 原因 | 修正 |
|---|---|---|
| 筆觸是黑色(想要彩色) | brushColorMode: 0 | 改為實際顏色 ID,如 6=橙, 9=藍, 30=紅 |
| 混色模式沒效果 | 背景是純黑或純白 | 用中間色背景,如 [180,160,140] |
| JSON 驗證失敗 | 遺漏必要欄位或類型錯誤 | 確認頂層有 9 個必填欄位(version, startTime, randomSeed, initialPathToggle, initialWhiteBrushMode, initialBrushColorMode, canvasSize, canvasBackgroundColor, events) |
| strokeData 遺漏欄位 | 缺少 brushDir / ctlNoise 等 | 確認 strokeData 包含全部 ~35 個欄位,特別是 targetflyBrushType, targetmainStrokeDir, brushDir, ctlNoise, brushPaintCtlNoisebyFrame, brushPaintInterpolationOffset, brushPaintOldRInitial |
| Flow 效果沒有顯示 | 缺少 forceMapParams 或未呼叫 getLastStrokeBounds() | 確保 forceMapParams 包含全部 16 個欄位,且先呼叫 getLastStrokeBounds() |
strokeData 完整欄位清單(速查)
生成 JSON 時,strokeData 必須包含以下全部約 35 個欄位:
進階工作流:分層迭代(像下棋一樣畫畫)
為什麼需要迭代?
一口氣生成全部筆觸時,你是在假設每一筆都完美執行。但實際上:
- 墨水擴散(feedback)和 Flow 效果有不可預測性 — 實際結果跟預期會有偏差
- 第 3 筆可能已經讓某區域太重,但你的 JSON 已經把第 4–10 筆都寫死了
- 構圖的「疏密平衡」只有看了實際畫面才能判斷
分層迭代讓你在每個階段看了再決定,而非全盤猜測。
四輪迭代流程
不需要一筆一筆(太慢),按繪畫層次分 4 輪:
| 輪次 | 內容 | 典型參數 | 提交後做什麼 |
|---|---|---|---|
| 1. 底層 | 2–3 筆大筆鋪底色 | bs=4–5, initialSize=80–103, brushMode=1 | 截圖 → 評估覆蓋率與重心 |
| 2. 結構 | 3–4 筆中筆建立構圖 | bs=2–3, initialSize=30–50, pathRotation=7–15 | 截圖 → 評估疏密平衡、哪裡要補 |
| 3. 細節 | 2–3 筆細筆勾勒 | bs=0.25–1, initialSize=2–15, brushMode=4 或 6 | 截圖 → 判斷是否需要白色提亮 |
| 4. 收尾 | 白色筆刷 / Flow / 或決定留白不加 | whiteBrushMode=true 或 Flow blendType=3 | 截圖 → 最終確認 |
每輪之間的評估框架
截圖後,回答這四個問題再生成下一輪:
- 重心 — 畫面的視覺重量偏向哪裡?需要在對側補筆嗎?
- 疏密 — 哪個區域太擠?哪個區域太空?留白是刻意的嗎?
- 層次關係 — 這一輪加的東西跟上一輪的關係是什麼?(覆蓋、對比、呼應)
- 完成度 — 還需要什麼才算「完成」?有沒有可能現在就是最好的狀態?
技術要點
- 分段 JSON 已支援 — 系統接受多次
loadRecordingFromText()呼叫,每次新的 JSON 會在現有畫面上繼續繪畫 - mouseCountStart 需要累加 — 第二輪的第一筆
mouseCountStart= 第一輪所有筆的 (mp + md) 事件總和。跨輪次要自己記住這個數字 - 每輪之間不需要特殊間隔 — 等上一輪播放完成後再提交下一輪即可。console 會印出播放完成訊息
- randomSeed 每輪可以不同 — 頂層 JSON 的
randomSeed每次提交可以用新的值
什麼時候用一口氣、什麼時候用迭代?
| 情境 | 建議 |
|---|---|
| 練習參數、測試筆刷效果 | 一口氣 — 快速產出看結果 |
| 有明確構圖(如臨摹) | 一口氣 — 計畫已定,不需要調整 |
| 自由創作、探索風格 | 迭代 — 需要邊畫邊感受 |
| 構圖複雜(>8 筆 + Flow) | 迭代 — 避免後半段的累積偏差 |
Cowork 共繪模式:你一筆我一筆
7.1 新增能力一覽(2026-04-06)
- Agent Toggle — 面板上的 Help 按鈕改為 Agent Toggle。按下後進入綠色
.agent-active狀態,所有筆觸的路徑資料會累積到window.agentPathBuffer,可透過window.getAgentPathData()讀出來讓 MCP / Claude Desktop 抓取。 - Append 模式 —
loadRecordingFromText(json, {append:true})不清空畫布,直接在現有內容上疊加播放。Mask 也保留。 - 首次呼叫 fallback — Fresh 頁面第一次呼叫
{append:true}會自動 fallback 到標準路徑(完整初始化 framebuffer),第二次以後才真正 append。不用先手動畫一筆暖機。 - Auto-fill strokeData — 呼叫
loadRecordingFromText前自動補齊缺失欄位,console 會列出補了哪些,並在 md 密度過低時警告。 - 便利色彩欄位 — 可以用
brushColorRGB:[r,g,b]或 HSB,自動轉成customBrushColor+brushColorMode:33。
7.2 「你一筆我一筆」工作流
- 人類先畫一筆 — 按下 Agent Toggle,在畫布上畫一筆,Toggle 會收集路徑資料。
- MCP 讀取路徑 — Claude in Chrome 執行
window.getAgentPathData()取出人類筆觸的 (x, y, pressure, time) 序列。 - Claude Desktop 回應 — 根據人類筆觸的位置、方向、顏色,生成一個回應筆觸的 JSON(遵守下方格式要求)。
- Append 播放 — 用
loadRecordingFromText(json, {append:true})把 AI 的回應畫在人類筆觸旁邊,不清空畫布。 - 循環 — 人類看完 AI 的回應後再畫一筆,繼續接龍。
🎯 設計精神
共繪不是「AI 幫我填滿畫布」,而是「AI 回應我這一筆」。每次只畫 1–3 筆,讓人類有足夠的空間主導構圖。AI 的角色是夥伴,不是代筆者。
7.3 JSON 格式:三個必守規則
brushMode必須是 1–7,絕對不能是 0。0 會靜默失敗(不報錯,也不畫東西)。
1=標準毛筆 / 2=簽字筆 / 3=哥特體 / 4=鋼筆 / 5=噴槍 / 6=飛筆 / 7=特殊brushColorMode是「色彩 ID」,不是混色模式。常用:0=黑, 1=白, 5=綠, 6=橙, 9=深藍, 10=紫, 30=紅, 31=黃, 32=藍, 33=自訂(配合customBrushColor:[r,g,b]), 34=珊瑚, 35=薄荷。x/y必須在mp/md/mr事件的頂層,不是塞在strokeData裡面。
其他細節:每筆 50–80 個 md 事件(間距 ~16ms),strokeData 約 40 個欄位(缺的會 auto-fill),mr 與下一個 mp 間隔至少 ~500ms 避免收筆被打斷。
7.4 五個可直接貼上 Console 的範例
以下範例都使用最小必要欄位,其餘會被 auto-fill 補齊。直接貼到 console 即可測試。前四筆用 append 接續畫,第五筆示範自訂 RGB。
格式重點:事件用 m:(不是 type:),最外層需要 version:"1.0",範例包成 IIFE 方便直接貼 console。
範例 1:藍色橫波(標準毛筆 / 粗)
範例 2:紅色螺旋(簽字筆)
範例 3:綠色 Z 字(標準毛筆 / 大)
範例 4:黃色花瓣(哥特體)
範例 5:紫色閃電(鋼筆)— 隨機抖動路徑
7.5 從 Agent Toggle 讀取人類筆觸
7.6 生成時的自我檢查
- □
brushMode∈ 1–7? - □
brushColorMode是色彩 ID(0–35),不是誤用 keyBlendMode? - □
x/y在事件頂層,不是在 strokeData 裡? - □ 每筆有 50+ 個
md事件? - □
mr與下一個mp間隔至少 500ms? - □ 使用
{append: true}而不是預設的清空模式? - □
canvasSize與當前畫布一致(或省略讓它跳過檢查)?
💡 Debug 技巧
貼到 console 後看輸出:
[autofill] 補齊欄位: ...— 正常,表示 auto-fill 有作用[autofill] 警告:md 密度過低— 筆觸會很短,增加 md 事件grid.js NaNwarnings — 檢查x/y是否在事件頂層- 沒錯誤但沒畫出來 — 99% 是
brushMode: 0或brushColorMode誤用