moonscratch 0.1.0-alpha.0
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/.agents/skills/moonbit-agent-guide/LICENSE +202 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.mbt.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/ide.md +116 -0
- package/.agents/skills/moonbit-agent-guide/references/advanced-moonbit-build.md +106 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.mbt.md +422 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.md +422 -0
- package/.agents/skills/moonbit-practice/SKILL.md +258 -0
- package/.agents/skills/moonbit-practice/assets/ci.yaml +25 -0
- package/.agents/skills/moonbit-practice/reference/agents.md +1469 -0
- package/.agents/skills/moonbit-practice/reference/configuration.md +228 -0
- package/.agents/skills/moonbit-practice/reference/ffi.md +229 -0
- package/.agents/skills/moonbit-practice/reference/ide.md +189 -0
- package/.agents/skills/moonbit-practice/reference/performance.md +217 -0
- package/.agents/skills/moonbit-practice/reference/refactor.md +154 -0
- package/.agents/skills/moonbit-practice/reference/stdlib.md +351 -0
- package/.agents/skills/moonbit-practice/reference/testing.md +228 -0
- package/.agents/skills/moonbit-refactoring/LICENSE +21 -0
- package/.agents/skills/moonbit-refactoring/SKILL.md +323 -0
- package/.githooks/README.md +23 -0
- package/.githooks/pre-commit +3 -0
- package/.github/workflows/copilot-setup-steps.yml +40 -0
- package/.turbo/turbo-typecheck.log +2 -0
- package/AGENTS.md +91 -0
- package/LICENSE +21 -0
- package/PLAN.md +64 -0
- package/README.mbt.md +77 -0
- package/README.md +84 -0
- package/TODO.md +120 -0
- package/a.png +0 -0
- package/benchmarks/calc.bench.ts +144 -0
- package/benchmarks/draw.bench.ts +215 -0
- package/benchmarks/load.bench.ts +28 -0
- package/benchmarks/render.bench.ts +53 -0
- package/benchmarks/run.bench.ts +8 -0
- package/benchmarks/types.d.ts +15 -0
- package/docs/scratch-vm-specs/eventloop.md +103 -0
- package/docs/scratch-vm-specs/moonscratch-time-separation.md +50 -0
- package/index.html +91 -0
- package/js/AGENTS.md +5 -0
- package/js/a.ts +52 -0
- package/js/assets/AGENTS.md +5 -0
- package/js/assets/base64.test.ts +14 -0
- package/js/assets/base64.ts +21 -0
- package/js/assets/build-asset.test.ts +26 -0
- package/js/assets/build-asset.ts +28 -0
- package/js/assets/create.test.ts +142 -0
- package/js/assets/create.ts +122 -0
- package/js/assets/index.test.ts +15 -0
- package/js/assets/index.ts +2 -0
- package/js/assets/types.ts +26 -0
- package/js/assets/validation.test.ts +34 -0
- package/js/assets/validation.ts +25 -0
- package/js/assets.test.ts +14 -0
- package/js/assets.ts +1 -0
- package/js/index.test.ts +26 -0
- package/js/index.ts +3 -0
- package/js/render/index.test.ts +65 -0
- package/js/render/index.ts +13 -0
- package/js/render/sharp.ts +87 -0
- package/js/render/svg.ts +68 -0
- package/js/render/types.ts +35 -0
- package/js/render/utils.ts +108 -0
- package/js/render/webgl.ts +274 -0
- package/js/sharp-optional.d.ts +16 -0
- package/js/test/helpers.ts +116 -0
- package/js/test/hikkaku-sample.test.ts +37 -0
- package/js/test/rubik-components.input-motion.test.ts +60 -0
- package/js/test/rubik-components.lists.test.ts +49 -0
- package/js/test/rubik-components.operators.test.ts +104 -0
- package/js/test/rubik-components.pen.test.ts +112 -0
- package/js/test/rubik-components.procedures-loops.test.ts +72 -0
- package/js/test/rubik-components.variables-branches.test.ts +57 -0
- package/js/test/rubik-components.visibility-entry.test.ts +31 -0
- package/js/test/test-projects.ts +598 -0
- package/js/test/variable.ts +200 -0
- package/js/test/warp.test.ts +59 -0
- package/js/vm/AGENTS.md +6 -0
- package/js/vm/README.md +183 -0
- package/js/vm/bindings.test.ts +13 -0
- package/js/vm/bindings.ts +5 -0
- package/js/vm/compare-operators.test.ts +145 -0
- package/js/vm/constants.test.ts +11 -0
- package/js/vm/constants.ts +4 -0
- package/js/vm/effect-guards.test.ts +68 -0
- package/js/vm/effect-guards.ts +44 -0
- package/js/vm/factory.test.ts +486 -0
- package/js/vm/factory.ts +615 -0
- package/js/vm/headless-vm.test.ts +131 -0
- package/js/vm/headless-vm.ts +342 -0
- package/js/vm/index.test.ts +28 -0
- package/js/vm/index.ts +5 -0
- package/js/vm/internal-types.ts +32 -0
- package/js/vm/json.test.ts +40 -0
- package/js/vm/json.ts +273 -0
- package/js/vm/normalize.test.ts +48 -0
- package/js/vm/normalize.ts +65 -0
- package/js/vm/options.test.ts +30 -0
- package/js/vm/options.ts +55 -0
- package/js/vm/pen-transparency.test.ts +115 -0
- package/js/vm/program-wasm.ts +322 -0
- package/js/vm/scheduler-render.test.ts +401 -0
- package/js/vm/scratch-assets.test.ts +136 -0
- package/js/vm/scratch-assets.ts +202 -0
- package/js/vm/types.ts +358 -0
- package/js/vm/value-guards.test.ts +25 -0
- package/js/vm/value-guards.ts +18 -0
- package/moon.mod.json +10 -0
- package/package.json +33 -0
- package/scripts/preinstall.ts +4 -0
- package/src/AGENTS.md +6 -0
- package/src/api.mbt +161 -0
- package/src/api_aot_commands.mbt +184 -0
- package/src/api_effects_json.mbt +72 -0
- package/src/api_options.mbt +60 -0
- package/src/api_program_wasm.mbt +1647 -0
- package/src/api_program_wat.mbt +2206 -0
- package/src/api_snapshot_json.mbt +44 -0
- package/src/cmd/AGENTS.md +5 -0
- package/src/cmd/main/AGENTS.md +5 -0
- package/src/cmd/main/main.mbt +29 -0
- package/src/cmd/main/moon.pkg +7 -0
- package/src/cmd/main/pkg.generated.mbti +13 -0
- package/src/json_helpers.mbt +176 -0
- package/src/moon.pkg +65 -0
- package/src/moonscratch.mbt +3 -0
- package/src/moonscratch_wbtest.mbt +40 -0
- package/src/parser_sb3.mbt +890 -0
- package/src/pkg.generated.mbti +479 -0
- package/src/runtime_eval.mbt +2844 -0
- package/src/runtime_exec.mbt +3850 -0
- package/src/runtime_render.mbt +2550 -0
- package/src/runtime_state.mbt +870 -0
- package/src/test/AGENTS.md +3 -0
- package/src/test/projects/AGENTS.md +6 -0
- package/src/test/projects/moon.pkg +4 -0
- package/src/test/projects/moonscratch_compat_test.mbt +642 -0
- package/src/test/projects/moonscratch_core_test.mbt +1332 -0
- package/src/test/projects/moonscratch_runtime_test.mbt +1087 -0
- package/src/test/projects/pkg.generated.mbti +13 -0
- package/src/test/projects/test_support.mbt +35 -0
- package/src/types_effects.mbt +20 -0
- package/src/types_error.mbt +4 -0
- package/src/types_options.mbt +31 -0
- package/src/types_runtime_structs.mbt +254 -0
- package/src/types_vm.mbt +109 -0
- package/tsconfig.json +29 -0
- package/viewer/index.ts +399 -0
- package/viewer/vite.d.ts +1 -0
- package/viewer/worker.ts +161 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { encodeBase64 } from './base64.ts'
|
|
2
|
+
import type { RgbaAsset } from './types.ts'
|
|
3
|
+
import { toByte, toPositiveInt } from './validation.ts'
|
|
4
|
+
|
|
5
|
+
export const buildAsset = (
|
|
6
|
+
width: number,
|
|
7
|
+
height: number,
|
|
8
|
+
rgba: ArrayLike<number>,
|
|
9
|
+
): RgbaAsset => {
|
|
10
|
+
const safeWidth = toPositiveInt(width, 'width')
|
|
11
|
+
const safeHeight = toPositiveInt(height, 'height')
|
|
12
|
+
const expectedLength = safeWidth * safeHeight * 4
|
|
13
|
+
|
|
14
|
+
if (rgba.length < expectedLength) {
|
|
15
|
+
throw new Error(`rgba length must be at least ${expectedLength}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const bytes = new Uint8Array(expectedLength)
|
|
19
|
+
for (let i = 0; i < expectedLength; i += 1) {
|
|
20
|
+
bytes[i] = toByte(rgba[i], `rgba[${i}]`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
width: safeWidth,
|
|
25
|
+
height: safeHeight,
|
|
26
|
+
rgbaBase64: encodeBase64(bytes),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fromCanvas,
|
|
5
|
+
fromImageBytes,
|
|
6
|
+
fromImageData,
|
|
7
|
+
fromImageFile,
|
|
8
|
+
fromRgbaBytes,
|
|
9
|
+
fromRgbaMatrix,
|
|
10
|
+
} from './create.ts'
|
|
11
|
+
|
|
12
|
+
const sharpCalls: unknown[] = []
|
|
13
|
+
|
|
14
|
+
vi.mock('sharp', () => ({
|
|
15
|
+
default: vi.fn((input?: unknown) => {
|
|
16
|
+
sharpCalls.push(input)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
ensureAlpha() {
|
|
20
|
+
return this
|
|
21
|
+
},
|
|
22
|
+
raw() {
|
|
23
|
+
return this
|
|
24
|
+
},
|
|
25
|
+
async toBuffer() {
|
|
26
|
+
return {
|
|
27
|
+
data: new Uint8Array([0, 255, 0, 255]),
|
|
28
|
+
info: {
|
|
29
|
+
width: 1,
|
|
30
|
+
height: 1,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
describe('moonscratch/js/assets/create.ts', () => {
|
|
39
|
+
test('builds an asset from image data', () => {
|
|
40
|
+
const asset = fromImageData({
|
|
41
|
+
width: 1,
|
|
42
|
+
height: 1,
|
|
43
|
+
data: new Uint8Array([0, 255, 0, 255]),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(asset).toEqual({
|
|
47
|
+
width: 1,
|
|
48
|
+
height: 1,
|
|
49
|
+
rgbaBase64: 'AP8A/w==',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('builds an asset from flat rgba bytes', () => {
|
|
54
|
+
const asset = fromRgbaBytes(
|
|
55
|
+
2,
|
|
56
|
+
1,
|
|
57
|
+
new Uint8Array([0, 255, 0, 255, 255, 0, 0, 255]),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(asset).toEqual({
|
|
61
|
+
width: 2,
|
|
62
|
+
height: 1,
|
|
63
|
+
rgbaBase64: 'AP8A//8AAP8=',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('builds an asset from H x W x 4 matrix', () => {
|
|
68
|
+
const asset = fromRgbaMatrix([
|
|
69
|
+
[
|
|
70
|
+
[0, 255, 0, 255],
|
|
71
|
+
[255, 0, 0, 255],
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
[0, 0, 255, 255],
|
|
75
|
+
[255, 255, 255, 255],
|
|
76
|
+
],
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
expect(asset).toEqual({
|
|
80
|
+
width: 2,
|
|
81
|
+
height: 2,
|
|
82
|
+
rgbaBase64: 'AP8A//8AAP8AAP///////w==',
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('builds an asset from canvas API', () => {
|
|
87
|
+
const getImageData = vi.fn(() => ({
|
|
88
|
+
width: 1,
|
|
89
|
+
height: 1,
|
|
90
|
+
data: new Uint8Array([255, 0, 0, 255]),
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
const getContext = vi.fn(() => ({ getImageData }))
|
|
94
|
+
|
|
95
|
+
const asset = fromCanvas({
|
|
96
|
+
width: 1,
|
|
97
|
+
height: 1,
|
|
98
|
+
getContext,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(getContext).toHaveBeenCalledWith('2d')
|
|
102
|
+
expect(getImageData).toHaveBeenCalledWith(0, 0, 1, 1)
|
|
103
|
+
expect(asset.rgbaBase64).toBe('/wAA/w==')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('throws when matrix row widths are inconsistent', () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
fromRgbaMatrix([
|
|
109
|
+
[
|
|
110
|
+
[0, 0, 0, 255],
|
|
111
|
+
[255, 255, 255, 255],
|
|
112
|
+
],
|
|
113
|
+
[[0, 0, 0, 255]],
|
|
114
|
+
]),
|
|
115
|
+
).toThrow('pixels[1] must contain exactly 2 columns')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('loads an image asset from bytes via sharp', async () => {
|
|
119
|
+
const asset = await fromImageBytes(new Uint8Array([1, 2, 3]))
|
|
120
|
+
|
|
121
|
+
expect(sharpCalls).toEqual([new Uint8Array([1, 2, 3])])
|
|
122
|
+
expect(asset).toEqual({
|
|
123
|
+
width: 1,
|
|
124
|
+
height: 1,
|
|
125
|
+
rgbaBase64: 'AP8A/w==',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('loads an image asset from file via sharp', async () => {
|
|
130
|
+
const asset = await fromImageFile('fixtures/example.png')
|
|
131
|
+
|
|
132
|
+
expect(sharpCalls).toEqual([
|
|
133
|
+
new Uint8Array([1, 2, 3]),
|
|
134
|
+
'fixtures/example.png',
|
|
135
|
+
])
|
|
136
|
+
expect(asset).toEqual({
|
|
137
|
+
width: 1,
|
|
138
|
+
height: 1,
|
|
139
|
+
rgbaBase64: 'AP8A/w==',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { buildAsset } from './build-asset.ts'
|
|
2
|
+
import type {
|
|
3
|
+
CanvasLike,
|
|
4
|
+
ImageDataLike,
|
|
5
|
+
RgbaAsset,
|
|
6
|
+
RgbaMatrix,
|
|
7
|
+
} from './types.ts'
|
|
8
|
+
import { toByte, toPositiveInt } from './validation.ts'
|
|
9
|
+
|
|
10
|
+
export const fromRgbaBytes = (
|
|
11
|
+
width: number,
|
|
12
|
+
height: number,
|
|
13
|
+
rgba: ArrayLike<number>,
|
|
14
|
+
): RgbaAsset => buildAsset(width, height, rgba)
|
|
15
|
+
|
|
16
|
+
export const fromImageData = (imageData: ImageDataLike): RgbaAsset =>
|
|
17
|
+
buildAsset(imageData.width, imageData.height, imageData.data)
|
|
18
|
+
|
|
19
|
+
export const fromCanvas = (canvas: CanvasLike): RgbaAsset => {
|
|
20
|
+
const width = toPositiveInt(canvas.width, 'canvas.width')
|
|
21
|
+
const height = toPositiveInt(canvas.height, 'canvas.height')
|
|
22
|
+
const context = canvas.getContext('2d')
|
|
23
|
+
|
|
24
|
+
if (!context) {
|
|
25
|
+
throw new Error('canvas.getContext("2d") returned null')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return fromImageData(context.getImageData(0, 0, width, height))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const fromRgbaMatrix = (pixels: RgbaMatrix): RgbaAsset => {
|
|
32
|
+
const height = pixels.length
|
|
33
|
+
if (height <= 0) {
|
|
34
|
+
throw new Error('pixels must have at least one row')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const firstRow = pixels[0]
|
|
38
|
+
const width = firstRow?.length ?? 0
|
|
39
|
+
if (width <= 0) {
|
|
40
|
+
throw new Error('pixels must have at least one column')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const flat = new Uint8Array(width * height * 4)
|
|
44
|
+
|
|
45
|
+
for (let y = 0; y < height; y += 1) {
|
|
46
|
+
const row = pixels[y]
|
|
47
|
+
if (!row || row.length !== width) {
|
|
48
|
+
throw new Error(`pixels[${y}] must contain exactly ${width} columns`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let x = 0; x < width; x += 1) {
|
|
52
|
+
const pixel = row[x]
|
|
53
|
+
if (!pixel || pixel.length < 4) {
|
|
54
|
+
throw new Error(`pixels[${y}][${x}] must contain 4 channels (RGBA)`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const base = (y * width + x) * 4
|
|
58
|
+
flat[base] = toByte(pixel[0], `pixels[${y}][${x}][0]`)
|
|
59
|
+
flat[base + 1] = toByte(pixel[1], `pixels[${y}][${x}][1]`)
|
|
60
|
+
flat[base + 2] = toByte(pixel[2], `pixels[${y}][${x}][2]`)
|
|
61
|
+
flat[base + 3] = toByte(pixel[3], `pixels[${y}][${x}][3]`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return buildAsset(width, height, flat)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type SharpNamespace = {
|
|
69
|
+
default?: (input?: unknown) => SharpPipeline
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type SharpPipeline = {
|
|
73
|
+
ensureAlpha(): SharpPipeline
|
|
74
|
+
raw(): SharpPipeline
|
|
75
|
+
toBuffer(options: { resolveWithObject: true }): Promise<{
|
|
76
|
+
data: Uint8Array
|
|
77
|
+
info: {
|
|
78
|
+
width: number
|
|
79
|
+
height: number
|
|
80
|
+
}
|
|
81
|
+
}>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const loadSharp = async (): Promise<(input?: unknown) => SharpPipeline> => {
|
|
85
|
+
try {
|
|
86
|
+
const sharp = (await import('sharp')) as SharpNamespace
|
|
87
|
+
return (
|
|
88
|
+
sharp.default ??
|
|
89
|
+
((sharp as unknown as { sharp?: (input?: unknown) => SharpPipeline })
|
|
90
|
+
.sharp as ((input?: unknown) => SharpPipeline) | undefined) ??
|
|
91
|
+
(() => {
|
|
92
|
+
throw new Error('invalid sharp module shape')
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
97
|
+
throw new Error(`sharp is required to load image assets: ${reason}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fromSharpPipeline = async (
|
|
102
|
+
pipeline: SharpPipeline,
|
|
103
|
+
): Promise<RgbaAsset> => {
|
|
104
|
+
const { data, info } = await pipeline
|
|
105
|
+
.ensureAlpha()
|
|
106
|
+
.raw()
|
|
107
|
+
.toBuffer({ resolveWithObject: true })
|
|
108
|
+
return fromRgbaBytes(info.width, info.height, data)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const fromImageBytes = async (
|
|
112
|
+
bytes: ArrayBuffer | Uint8Array,
|
|
113
|
+
): Promise<RgbaAsset> => {
|
|
114
|
+
const sharp = await loadSharp()
|
|
115
|
+
const normalized = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
116
|
+
return fromSharpPipeline(sharp(normalized))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const fromImageFile = async (path: string): Promise<RgbaAsset> => {
|
|
120
|
+
const sharp = await loadSharp()
|
|
121
|
+
return fromSharpPipeline(sharp(path))
|
|
122
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import * as create from './create.ts'
|
|
4
|
+
import * as index from './index.ts'
|
|
5
|
+
|
|
6
|
+
describe('moonscratch/js/assets/index.ts', () => {
|
|
7
|
+
test('re-exports create helpers', () => {
|
|
8
|
+
expect(index.fromRgbaBytes).toBe(create.fromRgbaBytes)
|
|
9
|
+
expect(index.fromImageData).toBe(create.fromImageData)
|
|
10
|
+
expect(index.fromCanvas).toBe(create.fromCanvas)
|
|
11
|
+
expect(index.fromRgbaMatrix).toBe(create.fromRgbaMatrix)
|
|
12
|
+
expect(index.fromImageBytes).toBe(create.fromImageBytes)
|
|
13
|
+
expect(index.fromImageFile).toBe(create.fromImageFile)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface RgbaAsset {
|
|
2
|
+
width: number
|
|
3
|
+
height: number
|
|
4
|
+
rgbaBase64: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type RgbaTuple = readonly [number, number, number, number]
|
|
8
|
+
export type RgbaMatrix = ReadonlyArray<
|
|
9
|
+
ReadonlyArray<RgbaTuple | ReadonlyArray<number>>
|
|
10
|
+
>
|
|
11
|
+
|
|
12
|
+
export interface ImageDataLike {
|
|
13
|
+
width: number
|
|
14
|
+
height: number
|
|
15
|
+
data: ArrayLike<number>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Canvas2DContextLike {
|
|
19
|
+
getImageData(sx: number, sy: number, sw: number, sh: number): ImageDataLike
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CanvasLike {
|
|
23
|
+
width: number
|
|
24
|
+
height: number
|
|
25
|
+
getContext(contextId: string, options?: unknown): Canvas2DContextLike | null
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { toByte, toPositiveInt } from './validation.ts'
|
|
4
|
+
|
|
5
|
+
describe('moonscratch/js/assets/validation.ts', () => {
|
|
6
|
+
test('toPositiveInt accepts finite positive integers', () => {
|
|
7
|
+
expect(toPositiveInt(1, 'width')).toBe(1)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('toPositiveInt rejects invalid values', () => {
|
|
11
|
+
expect(() => toPositiveInt(Number.NaN, 'width')).toThrow(
|
|
12
|
+
'width must be a finite number',
|
|
13
|
+
)
|
|
14
|
+
expect(() => toPositiveInt(1.5, 'height')).toThrow(
|
|
15
|
+
'height must be an integer',
|
|
16
|
+
)
|
|
17
|
+
expect(() => toPositiveInt(0, 'height')).toThrow(
|
|
18
|
+
'height must be greater than 0',
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('toByte accepts byte values', () => {
|
|
23
|
+
expect(toByte(255, 'rgba[0]')).toBe(255)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('toByte rejects out-of-range values', () => {
|
|
27
|
+
expect(() => toByte(-1, 'rgba[0]')).toThrow(
|
|
28
|
+
'rgba[0] must be between 0 and 255',
|
|
29
|
+
)
|
|
30
|
+
expect(() => toByte(256, 'rgba[1]')).toThrow(
|
|
31
|
+
'rgba[1] must be between 0 and 255',
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const toPositiveInt = (value: unknown, name: string): number => {
|
|
2
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
3
|
+
throw new Error(`${name} must be a finite number`)
|
|
4
|
+
}
|
|
5
|
+
if (!Number.isInteger(value)) {
|
|
6
|
+
throw new Error(`${name} must be an integer`)
|
|
7
|
+
}
|
|
8
|
+
if (value <= 0) {
|
|
9
|
+
throw new Error(`${name} must be greater than 0`)
|
|
10
|
+
}
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const toByte = (value: unknown, name: string): number => {
|
|
15
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
16
|
+
throw new Error(`${name} must be a finite number`)
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isInteger(value)) {
|
|
19
|
+
throw new Error(`${name} must be an integer`)
|
|
20
|
+
}
|
|
21
|
+
if (value < 0 || value > 255) {
|
|
22
|
+
throw new Error(`${name} must be between 0 and 255`)
|
|
23
|
+
}
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vite-plus/test'
|
|
2
|
+
import * as assetsIndex from './assets/index.ts'
|
|
3
|
+
import * as assets from './assets.ts'
|
|
4
|
+
|
|
5
|
+
describe('moonscratch/js/assets.ts', () => {
|
|
6
|
+
test('re-exports public asset APIs', () => {
|
|
7
|
+
expect(assets.fromRgbaBytes).toBe(assetsIndex.fromRgbaBytes)
|
|
8
|
+
expect(assets.fromImageData).toBe(assetsIndex.fromImageData)
|
|
9
|
+
expect(assets.fromCanvas).toBe(assetsIndex.fromCanvas)
|
|
10
|
+
expect(assets.fromRgbaMatrix).toBe(assetsIndex.fromRgbaMatrix)
|
|
11
|
+
expect(assets.fromImageBytes).toBe(assetsIndex.fromImageBytes)
|
|
12
|
+
expect(assets.fromImageFile).toBe(assetsIndex.fromImageFile)
|
|
13
|
+
})
|
|
14
|
+
})
|
package/js/assets.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './assets/index.ts'
|
package/js/index.test.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import * as assetsIndex from './assets/index.ts'
|
|
4
|
+
import * as index from './index.ts'
|
|
5
|
+
import * as renderIndex from './render/index.ts'
|
|
6
|
+
import * as vmIndex from './vm/index.ts'
|
|
7
|
+
|
|
8
|
+
describe('moonscratch/js/index.ts', () => {
|
|
9
|
+
test('re-exports vm and assets APIs', () => {
|
|
10
|
+
expect(index.createHeadlessVM).toBe(vmIndex.createHeadlessVM)
|
|
11
|
+
expect(index.createVM).toBe(vmIndex.createVM)
|
|
12
|
+
expect(index.moonscratch).toBe(vmIndex.moonscratch)
|
|
13
|
+
|
|
14
|
+
expect(index.fromRgbaBytes).toBe(assetsIndex.fromRgbaBytes)
|
|
15
|
+
expect(index.fromImageData).toBe(assetsIndex.fromImageData)
|
|
16
|
+
expect(index.fromCanvas).toBe(assetsIndex.fromCanvas)
|
|
17
|
+
expect(index.fromRgbaMatrix).toBe(assetsIndex.fromRgbaMatrix)
|
|
18
|
+
expect(index.fromImageBytes).toBe(assetsIndex.fromImageBytes)
|
|
19
|
+
expect(index.fromImageFile).toBe(assetsIndex.fromImageFile)
|
|
20
|
+
|
|
21
|
+
expect(index.renderWithSVG).toBe(renderIndex.renderWithSVG)
|
|
22
|
+
expect(index.renderWithSharp).toBe(renderIndex.renderWithSharp)
|
|
23
|
+
expect(index.renderWithWebGL).toBe(renderIndex.renderWithWebGL)
|
|
24
|
+
expect(index.normalizeRenderFrame).toBe(renderIndex.normalizeRenderFrame)
|
|
25
|
+
})
|
|
26
|
+
})
|
package/js/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { renderWithSharp, renderWithSVG, renderWithWebGL } from './index.ts'
|
|
4
|
+
|
|
5
|
+
const sharpCalls: unknown[] = []
|
|
6
|
+
|
|
7
|
+
vi.mock('sharp', () => ({
|
|
8
|
+
default: vi.fn((input?: unknown, options?: unknown) => {
|
|
9
|
+
sharpCalls.push([input, options])
|
|
10
|
+
return {
|
|
11
|
+
raw() {
|
|
12
|
+
return this
|
|
13
|
+
},
|
|
14
|
+
png() {
|
|
15
|
+
return this
|
|
16
|
+
},
|
|
17
|
+
jpeg() {
|
|
18
|
+
return this
|
|
19
|
+
},
|
|
20
|
+
webp() {
|
|
21
|
+
return this
|
|
22
|
+
},
|
|
23
|
+
async toBuffer() {
|
|
24
|
+
return Buffer.from([1, 2, 3, 4])
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
describe('moonscratch/js/render', () => {
|
|
31
|
+
const frame = {
|
|
32
|
+
width: 2,
|
|
33
|
+
height: 2,
|
|
34
|
+
pixels: new Uint8Array([
|
|
35
|
+
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
|
|
36
|
+
]),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('renderWithSVG builds a compact svg payload', () => {
|
|
40
|
+
const svg = renderWithSVG(frame)
|
|
41
|
+
expect(svg).toContain('<svg')
|
|
42
|
+
expect(svg).toContain('shape-rendering="crispEdges"')
|
|
43
|
+
expect(svg).toContain('fill="rgb(255,0,0)"')
|
|
44
|
+
expect(svg).toContain('</svg>')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('renderWithSharp calls sharp and returns buffer', async () => {
|
|
48
|
+
const buffer = await renderWithSharp(frame)
|
|
49
|
+
expect(sharpCalls).toEqual([
|
|
50
|
+
[frame.pixels, { raw: { width: 2, height: 2, channels: 4 } }],
|
|
51
|
+
])
|
|
52
|
+
expect(buffer).toEqual(Buffer.from([1, 2, 3, 4]))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('renderWithWebGL throws when canvas is not available', () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
renderWithWebGL({
|
|
58
|
+
...frame,
|
|
59
|
+
width: 1,
|
|
60
|
+
height: 1,
|
|
61
|
+
pixels: new Uint8Array([0, 0, 0, 255]),
|
|
62
|
+
}),
|
|
63
|
+
).toThrow('renderWithWebGL')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { renderWithSharp } from './sharp.ts'
|
|
2
|
+
export { renderWithSVG } from './svg.ts'
|
|
3
|
+
export type {
|
|
4
|
+
RenderFrame,
|
|
5
|
+
RenderFrameLike,
|
|
6
|
+
RenderImageData,
|
|
7
|
+
RenderWithSharpOptions,
|
|
8
|
+
RenderWithWebGLOptions,
|
|
9
|
+
RenderWithWebGLResult,
|
|
10
|
+
WebGLRenderResult,
|
|
11
|
+
} from './types.ts'
|
|
12
|
+
export { normalizeRenderFrame, normalizeRenderFrameTrusted } from './utils.ts'
|
|
13
|
+
export { renderWithWebGL } from './webgl.ts'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RenderFrame, RenderWithSharpOptions } from './types.ts'
|
|
2
|
+
import { normalizeRenderFrame } from './utils.ts'
|
|
3
|
+
|
|
4
|
+
type SharpNamespace = {
|
|
5
|
+
default?: SharpFactory
|
|
6
|
+
sharp?: SharpFactory
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SharpFactory = (input?: unknown, options?: unknown) => SharpPipeline
|
|
10
|
+
|
|
11
|
+
type SharpPipeline = {
|
|
12
|
+
raw?(): SharpPipeline
|
|
13
|
+
png?(): SharpPipeline
|
|
14
|
+
jpeg?(): SharpPipeline
|
|
15
|
+
webp?(): SharpPipeline
|
|
16
|
+
toBuffer(): Promise<unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const loadSharp = async (): Promise<
|
|
20
|
+
(input?: unknown, options?: unknown) => SharpPipeline
|
|
21
|
+
> => {
|
|
22
|
+
try {
|
|
23
|
+
const sharp = (await import('sharp')) as unknown as SharpNamespace
|
|
24
|
+
if (sharp.default) {
|
|
25
|
+
return sharp.default
|
|
26
|
+
}
|
|
27
|
+
const direct = sharp.sharp
|
|
28
|
+
if (typeof direct === 'function') {
|
|
29
|
+
return direct
|
|
30
|
+
}
|
|
31
|
+
throw new Error('invalid sharp module shape')
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
34
|
+
throw new Error(
|
|
35
|
+
`sharp is required to render PNG/WebP/JPEG output: ${reason}`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const renderWithSharp = async (
|
|
41
|
+
input: RenderFrame,
|
|
42
|
+
options: RenderWithSharpOptions = {},
|
|
43
|
+
): Promise<Buffer> => {
|
|
44
|
+
const frame = normalizeRenderFrame(input)
|
|
45
|
+
if (frame.width <= 0 || frame.height <= 0) {
|
|
46
|
+
return Buffer.from([])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sharp = await loadSharp()
|
|
50
|
+
const pipeline = sharp(frame.pixels, {
|
|
51
|
+
raw: { width: frame.width, height: frame.height, channels: 4 },
|
|
52
|
+
})
|
|
53
|
+
const format = options.format ?? 'png'
|
|
54
|
+
let current = pipeline
|
|
55
|
+
switch (format) {
|
|
56
|
+
case 'png':
|
|
57
|
+
if (typeof current.png === 'function') {
|
|
58
|
+
current = current.png()
|
|
59
|
+
}
|
|
60
|
+
break
|
|
61
|
+
case 'jpeg':
|
|
62
|
+
case 'jpg':
|
|
63
|
+
if (typeof current.jpeg === 'function') {
|
|
64
|
+
current = current.jpeg()
|
|
65
|
+
}
|
|
66
|
+
break
|
|
67
|
+
case 'webp':
|
|
68
|
+
if (typeof current.webp === 'function') {
|
|
69
|
+
current = current.webp()
|
|
70
|
+
}
|
|
71
|
+
break
|
|
72
|
+
default:
|
|
73
|
+
throw new Error(`unsupported sharp output format: ${String(format)}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof current.toBuffer !== 'function') {
|
|
77
|
+
throw new Error('sharp pipeline does not expose toBuffer')
|
|
78
|
+
}
|
|
79
|
+
const rawBuffer = await current.toBuffer()
|
|
80
|
+
if (rawBuffer instanceof Buffer) {
|
|
81
|
+
return rawBuffer
|
|
82
|
+
}
|
|
83
|
+
if (rawBuffer instanceof Uint8Array) {
|
|
84
|
+
return Buffer.from(rawBuffer)
|
|
85
|
+
}
|
|
86
|
+
throw new Error('sharp returned unsupported buffer type')
|
|
87
|
+
}
|
package/js/render/svg.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RenderFrame } from './types.ts'
|
|
2
|
+
import { normalizeRenderFrame } from './utils.ts'
|
|
3
|
+
|
|
4
|
+
const appendRect = (
|
|
5
|
+
parts: string[],
|
|
6
|
+
x: number,
|
|
7
|
+
y: number,
|
|
8
|
+
width: number,
|
|
9
|
+
rgba: [number, number, number, number],
|
|
10
|
+
): void => {
|
|
11
|
+
const [r, g, b, a] = rgba
|
|
12
|
+
if (width <= 0 || a <= 0) {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
const opacity = a < 255 ? ` fill-opacity="${a / 255}"` : ''
|
|
16
|
+
parts.push(
|
|
17
|
+
`<rect x="${x}" y="${y}" width="${width}" height="1" fill="rgb(${r},${g},${b})"${opacity}/>`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isPixelEqual = (
|
|
22
|
+
pixels: Uint8Array,
|
|
23
|
+
base: number,
|
|
24
|
+
otherBase: number,
|
|
25
|
+
): boolean =>
|
|
26
|
+
pixels[base] === pixels[otherBase] &&
|
|
27
|
+
pixels[base + 1] === pixels[otherBase + 1] &&
|
|
28
|
+
pixels[base + 2] === pixels[otherBase + 2] &&
|
|
29
|
+
pixels[base + 3] === pixels[otherBase + 3]
|
|
30
|
+
|
|
31
|
+
const pixelByte = (value: number | undefined): number => {
|
|
32
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const renderWithSVG = (frame: RenderFrame): string => {
|
|
39
|
+
const normalized = normalizeRenderFrame(frame)
|
|
40
|
+
const { width, height, pixels } = normalized
|
|
41
|
+
const parts = [
|
|
42
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">`,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (let y = 0; y < height; y += 1) {
|
|
46
|
+
let x = 0
|
|
47
|
+
while (x < width) {
|
|
48
|
+
const base = (y * width + x) * 4
|
|
49
|
+
const r = pixelByte(pixels[base])
|
|
50
|
+
const g = pixelByte(pixels[base + 1])
|
|
51
|
+
const b = pixelByte(pixels[base + 2])
|
|
52
|
+
const a = pixelByte(pixels[base + 3])
|
|
53
|
+
let runWidth = 1
|
|
54
|
+
while (x + runWidth < width) {
|
|
55
|
+
const nextBase = (y * width + x + runWidth) * 4
|
|
56
|
+
if (!isPixelEqual(pixels, base, nextBase)) {
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
runWidth += 1
|
|
60
|
+
}
|
|
61
|
+
appendRect(parts, x, y, runWidth, [r, g, b, a])
|
|
62
|
+
x += runWidth
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
parts.push('</svg>')
|
|
67
|
+
return parts.join('')
|
|
68
|
+
}
|