evomeme 0.1.7 → 0.1.9-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/canvas-public/app.js +8517 -1692
  2. package/dist/canvas-public/assets/presets/catalog.json +47 -0
  3. package/dist/canvas-public/assets/presets/tiles/A_01_CaoDi.png +0 -0
  4. package/dist/canvas-public/assets/presets/tiles/A_02_ShaMo.png +0 -0
  5. package/dist/canvas-public/assets/presets/tiles/A_03_TianKong.png +0 -0
  6. package/dist/canvas-public/assets/presets/tiles/A_04_ShuiBo.png +0 -0
  7. package/dist/canvas-public/assets/presets/tiles/A_05_HaiMian.png +0 -0
  8. package/dist/canvas-public/assets/presets/tiles/A_06_MuDiBan.png +0 -0
  9. package/dist/canvas-public/assets/presets/tiles/A_07_ShiZhuan.png +0 -0
  10. package/dist/canvas-public/assets/presets/tiles/A_08_AnRongYan.png +0 -0
  11. package/dist/canvas-public/assets/presets/tiles/A_09_QingTaiShi.png +0 -0
  12. package/dist/canvas-public/assets/presets/tiles/A_10_ShenHai.png +0 -0
  13. package/dist/canvas-public/assets/presets/tiles/A_11_HuiYanShi.png +0 -0
  14. package/dist/canvas-public/assets/presets/tiles/A_12_HongTu.png +0 -0
  15. package/dist/canvas-public/assets/presets/tiles/A_13_GengDi.png +0 -0
  16. package/dist/canvas-public/assets/presets/tiles/A_14_HuoShanYan.png +0 -0
  17. package/dist/canvas-public/assets/presets/tiles/A_15_NiTu.png +0 -0
  18. package/dist/canvas-public/assets/presets/tiles/A_16_HuangSha.png +0 -0
  19. package/dist/canvas-public/assets/presets/tiles/A_17_CongLin.png +0 -0
  20. package/dist/canvas-public/assets/presets/tiles/A_18_ZiYanShi.png +0 -0
  21. package/dist/canvas-public/assets/presets/tiles/A_19_MoGuTu.png +0 -0
  22. package/dist/canvas-public/assets/presets/tiles/A_20_HuaDian.png +0 -0
  23. package/dist/canvas-public/assets/presets/tiles/A_21_HaiLang.png +0 -0
  24. package/dist/canvas-public/assets/presets/tiles/A_22_GanTu.png +0 -0
  25. package/dist/canvas-public/assets/presets/tiles/A_23_QingTaiShiBan.png +0 -0
  26. package/dist/canvas-public/assets/presets/tiles/A_24_XingKong.png +0 -0
  27. package/dist/canvas-public/assets/presets/tiles/A_25_MoFaFuWen.png +0 -0
  28. package/dist/canvas-public/assets/presets/tiles/A_26_ShiTuYeDi.png +0 -0
  29. package/dist/canvas-public/assets/presets/tiles/A_27_LanZhuan.png +0 -0
  30. package/dist/canvas-public/assets/presets/tiles/A_28_HongZhuan.png +0 -0
  31. package/dist/canvas-public/assets/presets/tiles/A_29_YueQiu.png +0 -0
  32. package/dist/canvas-public/assets/presets/tiles/A_30_JinShuGe.png +0 -0
  33. package/dist/canvas-public/assets/presets/tiles/A_31_JinShuJingGai.png +0 -0
  34. package/dist/canvas-public/assets/presets/tiles/A_32_ShuZiChuang.png +0 -0
  35. package/dist/canvas-public/color-key-worker.js +280 -0
  36. package/dist/canvas-public/image-canvas-size-editor.js +254 -0
  37. package/dist/canvas-public/index.html +226 -119
  38. package/dist/canvas-public/perfect-pixel-worker.js +499 -0
  39. package/dist/canvas-public/smart-split.js +1 -1
  40. package/dist/canvas-public/styles.css +3902 -1225
  41. package/dist/canvas-public/tile-map-editor.js +2120 -0
  42. package/dist/canvas-public/ui-controls.js +95 -0
  43. package/dist/canvas-public/vendor/pixi-tilemap.js +126 -0
  44. package/dist/canvas-public/vendor/pixi.min.js +2340 -0
  45. package/dist/index.js +32263 -4187
  46. package/dist/stage-blender/bake_materials.py +102 -0
  47. package/dist/stage-blender/export_scene.py +275 -0
  48. package/dist/stage-blender/preview_camera.py +90 -0
  49. package/dist/stage-blender/repair_mesh.py +112 -0
  50. package/dist/stage-public/app.js +759 -0
  51. package/dist/stage-public/baked/bakePilot.js +44 -0
  52. package/dist/stage-public/baked/bakedScene.js +129 -0
  53. package/dist/stage-public/baked/sceneAppearance.js +225 -0
  54. package/dist/stage-public/baked/sceneCamera.js +45 -0
  55. package/dist/stage-public/comparison-contract.js +28 -0
  56. package/dist/stage-public/delivery.html +4 -0
  57. package/dist/stage-public/delivery.js +17 -0
  58. package/dist/stage-public/godot/appearance.gd +170 -0
  59. package/dist/stage-public/godot/preview.gd +79 -0
  60. package/dist/stage-public/index.html +141 -0
  61. package/dist/stage-public/review-app.js +289 -0
  62. package/dist/stage-public/review-runtime.js +63 -0
  63. package/dist/stage-public/review.css +24 -0
  64. package/dist/stage-public/review.html +47 -0
  65. package/dist/stage-public/scene-runtime.js +212 -0
  66. package/dist/stage-public/sceneContractRuntime.js +235 -0
  67. package/dist/stage-public/styles.css +328 -0
  68. package/dist/stage-public/three-appearance.js +104 -0
  69. package/dist/stage-public/vendor/three170/LICENSE +21 -0
  70. package/dist/stage-public/vendor/three170/build/three.module.js +54571 -0
  71. package/dist/stage-public/vendor/three170/examples/jsm/controls/OrbitControls.js +1523 -0
  72. package/dist/stage-public/vendor/three170/examples/jsm/environments/RoomEnvironment.js +144 -0
  73. package/dist/stage-public/vendor/three170/examples/jsm/libs/fflate.module.js +2672 -0
  74. package/dist/stage-public/vendor/three170/examples/jsm/loaders/EXRLoader.js +2563 -0
  75. package/dist/stage-public/vendor/three170/examples/jsm/loaders/GLTFLoader.js +4727 -0
  76. package/dist/stage-public/vendor/three170/examples/jsm/utils/BufferGeometryUtils.js +1373 -0
  77. package/dist/tile-transition-mask.svg +40 -0
  78. package/package.json +77 -69
  79. package/skills/evomeme-canvas/SKILL.md +52 -0
  80. package/skills/evomeme-canvas/agents/openai.yaml +7 -0
  81. package/skills/evomeme-cover/SKILL.md +1 -1
  82. package/skills/evomeme-game-maker/SKILL.md +79 -8
  83. package/skills/game-asset-align/SKILL.md +45 -0
  84. package/skills/game-asset-align/agents/openai.yaml +8 -0
  85. package/skills/game-asset-align/references/alignment-contract.md +61 -0
  86. package/skills/game-asset-align/references/blender-scene-pipeline.md +45 -0
  87. package/skills/game-asset-align/references/canonical-package.md +26 -0
  88. package/skills/game-asset-align/references/engine-capabilities.md +14 -0
  89. package/skills/game-asset-align/references/lightweight-review.md +32 -0
  90. package/skills/game-asset-align/references/repair-policy.md +30 -0
  91. package/skills/game-asset-align/references/tool-contract.md +39 -0
  92. package/skills/game-asset-align/references/visual-review.md +26 -0
  93. package/skills/game-asset-image-generator/SKILL.md +1 -1
  94. package/skills/godot-3d-game/SKILL.md +39 -0
  95. package/skills/godot-3d-game/agents/openai.yaml +8 -0
  96. package/skills/godot-3d-game/references/alignment-gates.md +26 -0
  97. package/skills/godot-3d-game/references/appearance-alignment.md +52 -0
  98. package/skills/godot-3d-game/references/build-and-qa.md +22 -0
  99. package/skills/godot-3d-game/references/canonical-asset-runtime.md +27 -0
  100. package/skills/godot-3d-game/references/import-and-wrapper-scenes.md +20 -0
  101. package/skills/godot-3d-game/references/physics-colliders.md +13 -0
  102. package/skills/godot-3d-game/references/project-structure.md +22 -0
  103. package/skills/godot-3d-game/references/skeleton-animation.md +16 -0
  104. package/skills/threejs-3d-game/SKILL.md +6 -2
  105. package/skills/threejs-3d-game/references/engine-asset-pipeline.md +52 -15
  106. package/dist/canvas-public/sequence-sheet.js +0 -59
@@ -0,0 +1,47 @@
1
+ {
2
+ "version": 1,
3
+ "categories": [
4
+ {
5
+ "id": "tiles",
6
+ "label": "地块",
7
+ "enabled": true,
8
+ "items": [
9
+ { "id": "tiles/A_01_CaoDi.png", "name": "草地", "file": "A_01_CaoDi.png" },
10
+ { "id": "tiles/A_02_ShaMo.png", "name": "沙漠", "file": "A_02_ShaMo.png" },
11
+ { "id": "tiles/A_03_TianKong.png", "name": "天空", "file": "A_03_TianKong.png" },
12
+ { "id": "tiles/A_04_ShuiBo.png", "name": "水波", "file": "A_04_ShuiBo.png" },
13
+ { "id": "tiles/A_05_HaiMian.png", "name": "海面", "file": "A_05_HaiMian.png" },
14
+ { "id": "tiles/A_06_MuDiBan.png", "name": "木地板", "file": "A_06_MuDiBan.png" },
15
+ { "id": "tiles/A_07_ShiZhuan.png", "name": "石砖", "file": "A_07_ShiZhuan.png" },
16
+ { "id": "tiles/A_08_AnRongYan.png", "name": "暗熔岩", "file": "A_08_AnRongYan.png" },
17
+ { "id": "tiles/A_09_QingTaiShi.png", "name": "青苔石", "file": "A_09_QingTaiShi.png" },
18
+ { "id": "tiles/A_10_ShenHai.png", "name": "深海", "file": "A_10_ShenHai.png" },
19
+ { "id": "tiles/A_11_HuiYanShi.png", "name": "灰岩石", "file": "A_11_HuiYanShi.png" },
20
+ { "id": "tiles/A_12_HongTu.png", "name": "红土", "file": "A_12_HongTu.png" },
21
+ { "id": "tiles/A_13_GengDi.png", "name": "耕地", "file": "A_13_GengDi.png" },
22
+ { "id": "tiles/A_14_HuoShanYan.png", "name": "火山岩", "file": "A_14_HuoShanYan.png" },
23
+ { "id": "tiles/A_15_NiTu.png", "name": "泥土", "file": "A_15_NiTu.png" },
24
+ { "id": "tiles/A_16_HuangSha.png", "name": "黄沙", "file": "A_16_HuangSha.png" },
25
+ { "id": "tiles/A_17_CongLin.png", "name": "丛林", "file": "A_17_CongLin.png" },
26
+ { "id": "tiles/A_18_ZiYanShi.png", "name": "紫岩石", "file": "A_18_ZiYanShi.png" },
27
+ { "id": "tiles/A_19_MoGuTu.png", "name": "蘑菇土", "file": "A_19_MoGuTu.png" },
28
+ { "id": "tiles/A_20_HuaDian.png", "name": "花甸", "file": "A_20_HuaDian.png" },
29
+ { "id": "tiles/A_21_HaiLang.png", "name": "海浪", "file": "A_21_HaiLang.png" },
30
+ { "id": "tiles/A_22_GanTu.png", "name": "干土", "file": "A_22_GanTu.png" },
31
+ { "id": "tiles/A_23_QingTaiShiBan.png", "name": "青苔石板", "file": "A_23_QingTaiShiBan.png" },
32
+ { "id": "tiles/A_24_XingKong.png", "name": "星空", "file": "A_24_XingKong.png" },
33
+ { "id": "tiles/A_25_MoFaFuWen.png", "name": "魔法符文", "file": "A_25_MoFaFuWen.png" },
34
+ { "id": "tiles/A_26_ShiTuYeDi.png", "name": "湿土野地", "file": "A_26_ShiTuYeDi.png" },
35
+ { "id": "tiles/A_27_LanZhuan.png", "name": "蓝砖", "file": "A_27_LanZhuan.png" },
36
+ { "id": "tiles/A_28_HongZhuan.png", "name": "红砖", "file": "A_28_HongZhuan.png" },
37
+ { "id": "tiles/A_29_YueQiu.png", "name": "月球", "file": "A_29_YueQiu.png" },
38
+ { "id": "tiles/A_30_JinShuGe.png", "name": "金属格", "file": "A_30_JinShuGe.png" },
39
+ { "id": "tiles/A_31_JinShuJingGai.png", "name": "金属井盖", "file": "A_31_JinShuJingGai.png" },
40
+ { "id": "tiles/A_32_ShuZiChuang.png", "name": "数字窗", "file": "A_32_ShuZiChuang.png" }
41
+ ]
42
+ },
43
+ { "id": "characters", "label": "角色", "enabled": false, "items": [] },
44
+ { "id": "props", "label": "道具", "enabled": false, "items": [] },
45
+ { "id": "backgrounds", "label": "背景", "enabled": false, "items": [] }
46
+ ]
47
+ }
@@ -0,0 +1,280 @@
1
+ let sourceBitmap = null
2
+ let previewSource = null
3
+ let previewLab = null
4
+ let previewWidth = 0
5
+ let previewHeight = 0
6
+ const linearChannel = new Float32Array(256)
7
+
8
+ for (let value = 0; value < linearChannel.length; value += 1) {
9
+ const channel = value / 255
10
+ linearChannel[value] = channel <= 0.04045
11
+ ? channel / 12.92
12
+ : ((channel + 0.055) / 1.055) ** 2.4
13
+ }
14
+
15
+ self.addEventListener('message', async (event) => {
16
+ const message = event.data || {}
17
+ try {
18
+ if (message.type === 'init') {
19
+ const response = await fetch(String(message.url || ''))
20
+ if (!response.ok) throw new Error(`图片载入失败:${response.status}`)
21
+ sourceBitmap?.close?.()
22
+ sourceBitmap = await createImageBitmap(await response.blob())
23
+ if (message.purpose !== 'exact') {
24
+ const scale = Math.min(1, 900 / Math.max(sourceBitmap.width, sourceBitmap.height))
25
+ previewWidth = Math.max(1, Math.round(sourceBitmap.width * scale))
26
+ previewHeight = Math.max(1, Math.round(sourceBitmap.height * scale))
27
+ const canvas = new OffscreenCanvas(previewWidth, previewHeight)
28
+ const context = canvas.getContext('2d', { willReadFrequently: true })
29
+ context.drawImage(sourceBitmap, 0, 0, previewWidth, previewHeight)
30
+ previewSource = context.getImageData(0, 0, previewWidth, previewHeight)
31
+ previewLab = prepareOklab(previewSource.data)
32
+ }
33
+ self.postMessage({
34
+ type: 'ready',
35
+ width: sourceBitmap.width,
36
+ height: sourceBitmap.height,
37
+ })
38
+ return
39
+ }
40
+
41
+ if (!sourceBitmap) throw new Error('颜色剔除 Worker 尚未初始化')
42
+ const color = normalizeColor(message.color)
43
+ const strength = normalizeStrength(message.strength)
44
+ if (message.type === 'preview') {
45
+ if (!previewSource || !previewLab) throw new Error('颜色剔除预览尚未初始化')
46
+ const pixels = applyColorKeyPixels(
47
+ previewSource.data,
48
+ color,
49
+ strength,
50
+ previewWidth,
51
+ previewHeight,
52
+ previewLab,
53
+ message.contiguousOnly === true,
54
+ message.despill === true,
55
+ )
56
+ const canvas = new OffscreenCanvas(previewWidth, previewHeight)
57
+ canvas.getContext('2d').putImageData(new ImageData(pixels, previewWidth, previewHeight), 0, 0)
58
+ const bitmap = canvas.transferToImageBitmap()
59
+ self.postMessage({ type: 'preview', requestId: message.requestId, bitmap }, [bitmap])
60
+ return
61
+ }
62
+
63
+ if (message.type === 'exact') {
64
+ const width = sourceBitmap.width
65
+ const height = sourceBitmap.height
66
+ const canvas = new OffscreenCanvas(width, height)
67
+ const context = canvas.getContext('2d', { willReadFrequently: true })
68
+ context.drawImage(sourceBitmap, 0, 0)
69
+ const source = context.getImageData(0, 0, width, height)
70
+ const pixels = applyColorKeyPixels(
71
+ source.data,
72
+ color,
73
+ strength,
74
+ width,
75
+ height,
76
+ null,
77
+ message.contiguousOnly === true,
78
+ message.despill === true,
79
+ )
80
+ context.putImageData(new ImageData(pixels, width, height), 0, 0)
81
+ const blob = await canvas.convertToBlob({ type: 'image/png' })
82
+ self.postMessage({
83
+ type: 'exact',
84
+ requestId: message.requestId,
85
+ blob,
86
+ width,
87
+ height,
88
+ })
89
+ }
90
+ } catch (error) {
91
+ self.postMessage({
92
+ type: 'error',
93
+ requestId: message.requestId,
94
+ message: String(error?.message || error),
95
+ })
96
+ }
97
+ })
98
+
99
+ function prepareOklab(source) {
100
+ const lab = new Float32Array((source.length / 4) * 3)
101
+ for (let offset = 0, labOffset = 0; offset < source.length; offset += 4, labOffset += 3) {
102
+ if (source[offset + 3] === 0) continue
103
+ const value = rgbToOklab(source[offset], source[offset + 1], source[offset + 2])
104
+ lab[labOffset] = value.l
105
+ lab[labOffset + 1] = value.a
106
+ lab[labOffset + 2] = value.b
107
+ }
108
+ return lab
109
+ }
110
+
111
+ function applyColorKeyPixels(
112
+ source,
113
+ color,
114
+ strength,
115
+ width,
116
+ height,
117
+ preparedLab = null,
118
+ contiguousOnly = false,
119
+ despill = false,
120
+ ) {
121
+ const pixels = new Uint8ClampedArray(source)
122
+ const target = parseHex(color)
123
+ const targetLab = rgbToOklab(target.red, target.green, target.blue)
124
+ const targetLinear = {
125
+ red: srgbByteToLinear(target.red),
126
+ green: srgbByteToLinear(target.green),
127
+ blue: srgbByteToLinear(target.blue),
128
+ }
129
+ const core = 0.006 + (strength / 100) * 0.19
130
+ const feather = 0.018 + (strength / 100) * 0.035
131
+ const spillFeather = feather * 2.6
132
+ const distances = new Float32Array(pixels.length / 4)
133
+
134
+ for (let offset = 0, labOffset = 0, index = 0; offset < pixels.length; offset += 4, labOffset += 3, index += 1) {
135
+ if (pixels[offset + 3] === 0) continue
136
+ let lightness
137
+ let greenRed
138
+ let blueYellow
139
+ if (preparedLab) {
140
+ lightness = preparedLab[labOffset]
141
+ greenRed = preparedLab[labOffset + 1]
142
+ blueYellow = preparedLab[labOffset + 2]
143
+ } else {
144
+ const lab = rgbToOklab(pixels[offset], pixels[offset + 1], pixels[offset + 2])
145
+ lightness = lab.l
146
+ greenRed = lab.a
147
+ blueYellow = lab.b
148
+ }
149
+ const lightnessDelta = lightness - targetLab.l
150
+ const greenRedDelta = greenRed - targetLab.a
151
+ const blueYellowDelta = blueYellow - targetLab.b
152
+ distances[index] = Math.sqrt(
153
+ lightnessDelta * lightnessDelta +
154
+ greenRedDelta * greenRedDelta +
155
+ blueYellowDelta * blueYellowDelta,
156
+ )
157
+ }
158
+ const connected = contiguousOnly
159
+ ? boundaryConnectedMask(distances, pixels, width, height, core + feather)
160
+ : null
161
+
162
+ for (let offset = 0, index = 0; offset < pixels.length; offset += 4, index += 1) {
163
+ const originalAlpha = pixels[offset + 3]
164
+ if (originalAlpha === 0 || (connected && connected[index] === 0)) continue
165
+ const distance = distances[index]
166
+ const unit = Math.max(0, Math.min(1, (distance - core) / feather))
167
+ const remaining = unit * unit * (3 - 2 * unit)
168
+ const spillUnit = Math.max(0, Math.min(1, (distance - core) / spillFeather))
169
+ const spillRemaining = spillUnit * spillUnit * (3 - 2 * spillUnit)
170
+ const outputAlpha = Math.round(originalAlpha * remaining)
171
+ pixels[offset + 3] = outputAlpha
172
+ if (outputAlpha === 0) {
173
+ pixels[offset] = 0
174
+ pixels[offset + 1] = 0
175
+ pixels[offset + 2] = 0
176
+ } else if (despill) {
177
+ // Alpha feathering alone leaves the outer antialiased pixels fully opaque,
178
+ // even though their RGB still contains some of the keyed background. Extend
179
+ // color decontamination beyond the transparency band without changing alpha.
180
+ const alphaContamination = 1 - remaining
181
+ const outerContamination = Math.min(0.45, (1 - spillRemaining) * 0.45)
182
+ const contamination = Math.min(0.96, Math.max(alphaContamination, outerContamination))
183
+ if (contamination <= 0.001) continue
184
+ const foregroundShare = 1 - contamination
185
+ pixels[offset] = linearToSrgbByte(
186
+ (srgbByteToLinear(pixels[offset]) - contamination * targetLinear.red) / foregroundShare,
187
+ )
188
+ pixels[offset + 1] = linearToSrgbByte(
189
+ (srgbByteToLinear(pixels[offset + 1]) - contamination * targetLinear.green) / foregroundShare,
190
+ )
191
+ pixels[offset + 2] = linearToSrgbByte(
192
+ (srgbByteToLinear(pixels[offset + 2]) - contamination * targetLinear.blue) / foregroundShare,
193
+ )
194
+ }
195
+ }
196
+ return pixels
197
+ }
198
+
199
+ function boundaryConnectedMask(distances, pixels, width, height, threshold) {
200
+ const count = width * height
201
+ const connected = new Uint8Array(count)
202
+ const queue = new Int32Array(count)
203
+ let head = 0
204
+ let tail = 0
205
+ const enqueue = (index) => {
206
+ if (
207
+ connected[index] !== 0 ||
208
+ pixels[index * 4 + 3] === 0 ||
209
+ distances[index] >= threshold
210
+ ) return
211
+ connected[index] = 1
212
+ queue[tail++] = index
213
+ }
214
+ for (let x = 0; x < width; x += 1) {
215
+ enqueue(x)
216
+ if (height > 1) enqueue((height - 1) * width + x)
217
+ }
218
+ for (let y = 1; y < height - 1; y += 1) {
219
+ enqueue(y * width)
220
+ if (width > 1) enqueue(y * width + width - 1)
221
+ }
222
+ while (head < tail) {
223
+ const index = queue[head++]
224
+ const x = index % width
225
+ const y = Math.floor(index / width)
226
+ if (x > 0) enqueue(index - 1)
227
+ if (x + 1 < width) enqueue(index + 1)
228
+ if (y > 0) enqueue(index - width)
229
+ if (y + 1 < height) enqueue(index + width)
230
+ }
231
+ return connected
232
+ }
233
+
234
+ function normalizeColor(value) {
235
+ const color = String(value || '').toLowerCase()
236
+ if (!/^#[0-9a-f]{6}$/.test(color)) throw new Error('颜色格式无效')
237
+ return color
238
+ }
239
+
240
+ function normalizeStrength(value) {
241
+ const strength = Number(value)
242
+ if (!Number.isFinite(strength) || strength < 0 || strength > 100) {
243
+ throw new Error('强度需为 0–100')
244
+ }
245
+ return strength
246
+ }
247
+
248
+ function parseHex(color) {
249
+ return {
250
+ red: Number.parseInt(color.slice(1, 3), 16),
251
+ green: Number.parseInt(color.slice(3, 5), 16),
252
+ blue: Number.parseInt(color.slice(5, 7), 16),
253
+ }
254
+ }
255
+
256
+ function srgbByteToLinear(value) {
257
+ return linearChannel[value]
258
+ }
259
+
260
+ function linearToSrgbByte(value) {
261
+ const channel = Math.max(0, Math.min(1, value))
262
+ const srgb = channel <= 0.0031308
263
+ ? channel * 12.92
264
+ : 1.055 * channel ** (1 / 2.4) - 0.055
265
+ return Math.round(Math.max(0, Math.min(1, srgb)) * 255)
266
+ }
267
+
268
+ function rgbToOklab(red, green, blue) {
269
+ const r = srgbByteToLinear(red)
270
+ const g = srgbByteToLinear(green)
271
+ const b = srgbByteToLinear(blue)
272
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
273
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
274
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
275
+ return {
276
+ l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
277
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
278
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
279
+ }
280
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * 图片画布尺寸编辑器:负责预览原图、拖动四条边缘,并把宽高输入转换为裁切/透明扩边参数。
3
+ */
4
+
5
+ const MAX_SIDE = 16_384
6
+ const MAX_PIXELS = 67_108_864
7
+ const PREVIEW_PADDING = 52
8
+
9
+ export class ImageCanvasSizeEditor {
10
+ constructor({ stage, canvas, frame, widthInput, heightInput, onChange }) {
11
+ this.stage = stage
12
+ this.canvas = canvas
13
+ this.frame = frame
14
+ this.widthInput = widthInput
15
+ this.heightInput = heightInput
16
+ this.onChange = onChange
17
+ this.image = null
18
+ this.sourceWidth = 0
19
+ this.sourceHeight = 0
20
+ this.edges = { left: 0, top: 0, right: 0, bottom: 0 }
21
+ this.view = null
22
+ this.drag = null
23
+ this.abortController = new AbortController()
24
+ this.resizeObserver = new ResizeObserver(() => this.render())
25
+ this.resizeObserver.observe(stage)
26
+ this.bindEvents()
27
+ }
28
+
29
+ bindEvents() {
30
+ const signal = this.abortController.signal
31
+ this.widthInput.addEventListener('change', () => this.applyWidth(), { signal })
32
+ this.heightInput.addEventListener('change', () => this.applyHeight(), { signal })
33
+ for (const handle of this.frame.querySelectorAll('[data-edge]')) {
34
+ handle.addEventListener('pointerdown', (event) => this.startDrag(event, handle.dataset.edge), { signal })
35
+ }
36
+ window.addEventListener('pointermove', (event) => this.moveDrag(event), { signal })
37
+ window.addEventListener('pointerup', (event) => this.endDrag(event), { signal })
38
+ window.addEventListener('pointercancel', (event) => this.endDrag(event), { signal })
39
+ }
40
+
41
+ async load(url) {
42
+ const image = new Image()
43
+ await new Promise((resolve, reject) => {
44
+ image.onload = resolve
45
+ image.onerror = () => reject(new Error('无法读取待编辑图片'))
46
+ image.src = url
47
+ })
48
+ this.image = image
49
+ this.sourceWidth = image.naturalWidth
50
+ this.sourceHeight = image.naturalHeight
51
+ this.reset()
52
+ return { width: this.sourceWidth, height: this.sourceHeight }
53
+ }
54
+
55
+ reset() {
56
+ this.edges = { left: 0, top: 0, right: 0, bottom: 0 }
57
+ this.render()
58
+ this.emitChange()
59
+ }
60
+
61
+ getValue() {
62
+ return {
63
+ ...this.edges,
64
+ sourceWidth: this.sourceWidth,
65
+ sourceHeight: this.sourceHeight,
66
+ width: this.sourceWidth + this.edges.left + this.edges.right,
67
+ height: this.sourceHeight + this.edges.top + this.edges.bottom,
68
+ }
69
+ }
70
+
71
+ isValid() {
72
+ const value = this.getValue()
73
+ return value.width >= 1 && value.height >= 1 &&
74
+ value.width <= MAX_SIDE && value.height <= MAX_SIDE &&
75
+ value.width * value.height <= MAX_PIXELS
76
+ }
77
+
78
+ setBusy(busy) {
79
+ this.widthInput.disabled = busy
80
+ this.heightInput.disabled = busy
81
+ for (const handle of this.frame.querySelectorAll('[data-edge]')) handle.disabled = busy
82
+ }
83
+
84
+ applyWidth() {
85
+ const width = Math.round(Number(this.widthInput.value))
86
+ if (!Number.isSafeInteger(width) || width < 1 || width > MAX_SIDE) {
87
+ this.render()
88
+ this.emitChange()
89
+ return
90
+ }
91
+ const difference = width - this.sourceWidth
92
+ this.edges.left = Math.floor(difference / 2)
93
+ this.edges.right = difference - this.edges.left
94
+ this.render()
95
+ this.emitChange()
96
+ }
97
+
98
+ applyHeight() {
99
+ const height = Math.round(Number(this.heightInput.value))
100
+ if (!Number.isSafeInteger(height) || height < 1 || height > MAX_SIDE) {
101
+ this.render()
102
+ this.emitChange()
103
+ return
104
+ }
105
+ const difference = height - this.sourceHeight
106
+ this.edges.top = Math.floor(difference / 2)
107
+ this.edges.bottom = difference - this.edges.top
108
+ this.render()
109
+ this.emitChange()
110
+ }
111
+
112
+ startDrag(event, edge) {
113
+ if (event.button !== 0 || !this.image || !['left', 'top', 'right', 'bottom'].includes(edge)) return
114
+ event.preventDefault()
115
+ event.stopPropagation()
116
+ const rect = this.stage.getBoundingClientRect()
117
+ this.drag = {
118
+ edge,
119
+ pointerId: event.pointerId,
120
+ startClientX: event.clientX,
121
+ startClientY: event.clientY,
122
+ startValue: this.edges[edge],
123
+ cssScaleX: rect.width / Math.max(1, this.stage.clientWidth),
124
+ cssScaleY: rect.height / Math.max(1, this.stage.clientHeight),
125
+ view: this.view,
126
+ }
127
+ event.currentTarget.setPointerCapture?.(event.pointerId)
128
+ }
129
+
130
+ moveDrag(event) {
131
+ const drag = this.drag
132
+ if (!drag || drag.pointerId !== event.pointerId || !drag.view) return
133
+ event.preventDefault()
134
+ const horizontal = drag.edge === 'left' || drag.edge === 'right'
135
+ const screenDelta = horizontal
136
+ ? (event.clientX - drag.startClientX) / Math.max(0.001, drag.cssScaleX)
137
+ : (event.clientY - drag.startClientY) / Math.max(0.001, drag.cssScaleY)
138
+ const pixelDelta = Math.round(screenDelta / Math.max(0.0001, drag.view.scale))
139
+ const direction = drag.edge === 'left' || drag.edge === 'top' ? -1 : 1
140
+ this.edges[drag.edge] = this.clampEdge(drag.edge, drag.startValue + pixelDelta * direction)
141
+ this.render(drag.view)
142
+ this.emitChange()
143
+ }
144
+
145
+ endDrag(event) {
146
+ if (!this.drag || this.drag.pointerId !== event.pointerId) return
147
+ this.drag = null
148
+ this.render()
149
+ this.emitChange()
150
+ }
151
+
152
+ clampEdge(edge, value) {
153
+ const rounded = Math.round(value)
154
+ if (edge === 'left' || edge === 'right') {
155
+ const other = edge === 'left' ? this.edges.right : this.edges.left
156
+ return Math.max(1 - this.sourceWidth - other, Math.min(MAX_SIDE - this.sourceWidth - other, rounded))
157
+ }
158
+ const other = edge === 'top' ? this.edges.bottom : this.edges.top
159
+ return Math.max(1 - this.sourceHeight - other, Math.min(MAX_SIDE - this.sourceHeight - other, rounded))
160
+ }
161
+
162
+ calculateView(width, height) {
163
+ const output = this.getValue()
164
+ const minX = Math.min(0, -this.edges.left)
165
+ const minY = Math.min(0, -this.edges.top)
166
+ const maxX = Math.max(this.sourceWidth, this.sourceWidth + this.edges.right)
167
+ const maxY = Math.max(this.sourceHeight, this.sourceHeight + this.edges.bottom)
168
+ const contentWidth = Math.max(1, maxX - minX)
169
+ const contentHeight = Math.max(1, maxY - minY)
170
+ const scale = Math.max(0.0001, Math.min(
171
+ (width - PREVIEW_PADDING * 2) / contentWidth,
172
+ (height - PREVIEW_PADDING * 2) / contentHeight,
173
+ ))
174
+ const contentLeft = (width - contentWidth * scale) / 2
175
+ const contentTop = (height - contentHeight * scale) / 2
176
+ const sourceX = contentLeft - minX * scale
177
+ const sourceY = contentTop - minY * scale
178
+ return {
179
+ scale,
180
+ sourceX,
181
+ sourceY,
182
+ outputX: sourceX - this.edges.left * scale,
183
+ outputY: sourceY - this.edges.top * scale,
184
+ outputWidth: output.width * scale,
185
+ outputHeight: output.height * scale,
186
+ }
187
+ }
188
+
189
+ render(fixedView = null) {
190
+ if (!this.image || !this.sourceWidth || !this.sourceHeight) return
191
+ const width = Math.max(1, this.stage.clientWidth)
192
+ const height = Math.max(1, this.stage.clientHeight)
193
+ const ratio = Math.min(2, window.devicePixelRatio || 1)
194
+ this.canvas.width = Math.round(width * ratio)
195
+ this.canvas.height = Math.round(height * ratio)
196
+ const context = this.canvas.getContext('2d')
197
+ context.setTransform(ratio, 0, 0, ratio, 0, 0)
198
+ context.clearRect(0, 0, width, height)
199
+ context.imageSmoothingEnabled = false
200
+ const baseView = fixedView || this.calculateView(width, height)
201
+ const output = this.getValue()
202
+ const view = {
203
+ ...baseView,
204
+ outputX: baseView.sourceX - this.edges.left * baseView.scale,
205
+ outputY: baseView.sourceY - this.edges.top * baseView.scale,
206
+ outputWidth: output.width * baseView.scale,
207
+ outputHeight: output.height * baseView.scale,
208
+ }
209
+ this.view = view
210
+
211
+ context.save()
212
+ context.beginPath()
213
+ context.rect(view.outputX, view.outputY, view.outputWidth, view.outputHeight)
214
+ context.clip()
215
+ context.drawImage(
216
+ this.image,
217
+ view.sourceX,
218
+ view.sourceY,
219
+ this.sourceWidth * view.scale,
220
+ this.sourceHeight * view.scale,
221
+ )
222
+ context.restore()
223
+
224
+ context.save()
225
+ context.setLineDash([5, 4])
226
+ context.strokeStyle = 'rgba(255, 255, 255, 0.38)'
227
+ context.lineWidth = 1
228
+ context.strokeRect(
229
+ Math.round(view.sourceX) + 0.5,
230
+ Math.round(view.sourceY) + 0.5,
231
+ Math.round(this.sourceWidth * view.scale),
232
+ Math.round(this.sourceHeight * view.scale),
233
+ )
234
+ context.restore()
235
+
236
+ this.frame.style.left = `${view.outputX}px`
237
+ this.frame.style.top = `${view.outputY}px`
238
+ this.frame.style.width = `${Math.max(1, view.outputWidth)}px`
239
+ this.frame.style.height = `${Math.max(1, view.outputHeight)}px`
240
+ this.widthInput.value = String(output.width)
241
+ this.heightInput.value = String(output.height)
242
+ }
243
+
244
+ emitChange() {
245
+ this.onChange?.(this.getValue(), this.isValid())
246
+ }
247
+
248
+ dispose() {
249
+ this.drag = null
250
+ this.resizeObserver.disconnect()
251
+ this.abortController.abort()
252
+ this.image = null
253
+ }
254
+ }