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.
- package/dist/canvas-public/app.js +8517 -1692
- package/dist/canvas-public/assets/presets/catalog.json +47 -0
- package/dist/canvas-public/assets/presets/tiles/A_01_CaoDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_02_ShaMo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_03_TianKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_04_ShuiBo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_05_HaiMian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_06_MuDiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_07_ShiZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_08_AnRongYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_09_QingTaiShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_10_ShenHai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_11_HuiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_12_HongTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_13_GengDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_14_HuoShanYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_15_NiTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_16_HuangSha.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_17_CongLin.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_18_ZiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_19_MoGuTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_20_HuaDian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_21_HaiLang.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_22_GanTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_23_QingTaiShiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_24_XingKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_25_MoFaFuWen.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_26_ShiTuYeDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_27_LanZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_28_HongZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_29_YueQiu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_30_JinShuGe.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_31_JinShuJingGai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_32_ShuZiChuang.png +0 -0
- package/dist/canvas-public/color-key-worker.js +280 -0
- package/dist/canvas-public/image-canvas-size-editor.js +254 -0
- package/dist/canvas-public/index.html +226 -119
- package/dist/canvas-public/perfect-pixel-worker.js +499 -0
- package/dist/canvas-public/smart-split.js +1 -1
- package/dist/canvas-public/styles.css +3902 -1225
- package/dist/canvas-public/tile-map-editor.js +2120 -0
- package/dist/canvas-public/ui-controls.js +95 -0
- package/dist/canvas-public/vendor/pixi-tilemap.js +126 -0
- package/dist/canvas-public/vendor/pixi.min.js +2340 -0
- package/dist/index.js +32263 -4187
- package/dist/stage-blender/bake_materials.py +102 -0
- package/dist/stage-blender/export_scene.py +275 -0
- package/dist/stage-blender/preview_camera.py +90 -0
- package/dist/stage-blender/repair_mesh.py +112 -0
- package/dist/stage-public/app.js +759 -0
- package/dist/stage-public/baked/bakePilot.js +44 -0
- package/dist/stage-public/baked/bakedScene.js +129 -0
- package/dist/stage-public/baked/sceneAppearance.js +225 -0
- package/dist/stage-public/baked/sceneCamera.js +45 -0
- package/dist/stage-public/comparison-contract.js +28 -0
- package/dist/stage-public/delivery.html +4 -0
- package/dist/stage-public/delivery.js +17 -0
- package/dist/stage-public/godot/appearance.gd +170 -0
- package/dist/stage-public/godot/preview.gd +79 -0
- package/dist/stage-public/index.html +141 -0
- package/dist/stage-public/review-app.js +289 -0
- package/dist/stage-public/review-runtime.js +63 -0
- package/dist/stage-public/review.css +24 -0
- package/dist/stage-public/review.html +47 -0
- package/dist/stage-public/scene-runtime.js +212 -0
- package/dist/stage-public/sceneContractRuntime.js +235 -0
- package/dist/stage-public/styles.css +328 -0
- package/dist/stage-public/three-appearance.js +104 -0
- package/dist/stage-public/vendor/three170/LICENSE +21 -0
- package/dist/stage-public/vendor/three170/build/three.module.js +54571 -0
- package/dist/stage-public/vendor/three170/examples/jsm/controls/OrbitControls.js +1523 -0
- package/dist/stage-public/vendor/three170/examples/jsm/environments/RoomEnvironment.js +144 -0
- package/dist/stage-public/vendor/three170/examples/jsm/libs/fflate.module.js +2672 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/EXRLoader.js +2563 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/GLTFLoader.js +4727 -0
- package/dist/stage-public/vendor/three170/examples/jsm/utils/BufferGeometryUtils.js +1373 -0
- package/dist/tile-transition-mask.svg +40 -0
- package/package.json +77 -69
- package/skills/evomeme-canvas/SKILL.md +52 -0
- package/skills/evomeme-canvas/agents/openai.yaml +7 -0
- package/skills/evomeme-cover/SKILL.md +1 -1
- package/skills/evomeme-game-maker/SKILL.md +79 -8
- package/skills/game-asset-align/SKILL.md +45 -0
- package/skills/game-asset-align/agents/openai.yaml +8 -0
- package/skills/game-asset-align/references/alignment-contract.md +61 -0
- package/skills/game-asset-align/references/blender-scene-pipeline.md +45 -0
- package/skills/game-asset-align/references/canonical-package.md +26 -0
- package/skills/game-asset-align/references/engine-capabilities.md +14 -0
- package/skills/game-asset-align/references/lightweight-review.md +32 -0
- package/skills/game-asset-align/references/repair-policy.md +30 -0
- package/skills/game-asset-align/references/tool-contract.md +39 -0
- package/skills/game-asset-align/references/visual-review.md +26 -0
- package/skills/game-asset-image-generator/SKILL.md +1 -1
- package/skills/godot-3d-game/SKILL.md +39 -0
- package/skills/godot-3d-game/agents/openai.yaml +8 -0
- package/skills/godot-3d-game/references/alignment-gates.md +26 -0
- package/skills/godot-3d-game/references/appearance-alignment.md +52 -0
- package/skills/godot-3d-game/references/build-and-qa.md +22 -0
- package/skills/godot-3d-game/references/canonical-asset-runtime.md +27 -0
- package/skills/godot-3d-game/references/import-and-wrapper-scenes.md +20 -0
- package/skills/godot-3d-game/references/physics-colliders.md +13 -0
- package/skills/godot-3d-game/references/project-structure.md +22 -0
- package/skills/godot-3d-game/references/skeleton-animation.md +16 -0
- package/skills/threejs-3d-game/SKILL.md +6 -2
- package/skills/threejs-3d-game/references/engine-asset-pipeline.md +52 -15
- 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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|