feat:优化数据
@@ -78,6 +78,7 @@
|
||||
</div>
|
||||
<div class="card list" id="list"></div>
|
||||
</div>
|
||||
<script src="./thumb-renderer.js"></script>
|
||||
<script src="./admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
16
admin.js
@@ -83,6 +83,22 @@ saveBtn.addEventListener("click", async () => {
|
||||
if (payload.sourceFormat === "angle-metal-auto-converted") {
|
||||
alert("已自动解构并转换为 GLSL,展示页可直接渲染。");
|
||||
}
|
||||
if (payload.id && typeof window.captureShaderThumbnail === "function") {
|
||||
try {
|
||||
const dataUrl = await window.captureShaderThumbnail(payload.code, payload.name);
|
||||
const thumbRes = await fetch(`${API_BASE}/${encodeURIComponent(payload.id)}/thumbnail`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pngBase64: dataUrl }),
|
||||
});
|
||||
if (!thumbRes.ok) {
|
||||
const t = await thumbRes.json().catch(() => ({}));
|
||||
console.warn("缩略图上传失败:", t.error || thumbRes.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("缩略图生成失败:", e);
|
||||
}
|
||||
}
|
||||
nameInput.value = "";
|
||||
authorInput.value = "";
|
||||
codeInput.value = "";
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"views": 11032,
|
||||
"likes": 192,
|
||||
"createdAt": "2026-04-02T01:57:49.494Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.312Z"
|
||||
},
|
||||
{
|
||||
"id": "b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354",
|
||||
@@ -17,7 +18,8 @@
|
||||
"views": 3849,
|
||||
"likes": 532,
|
||||
"createdAt": "2026-04-02T01:56:37.273Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.455Z"
|
||||
},
|
||||
{
|
||||
"id": "50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0",
|
||||
@@ -27,7 +29,8 @@
|
||||
"views": 5343,
|
||||
"likes": 361,
|
||||
"createdAt": "2026-04-02T01:53:17.707Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.597Z"
|
||||
},
|
||||
{
|
||||
"id": "dd9bfa65-1443-45b5-a83b-d25538b2ddbd",
|
||||
@@ -37,7 +40,8 @@
|
||||
"views": 6478,
|
||||
"likes": 386,
|
||||
"createdAt": "2026-04-02T01:48:43.430Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.728Z"
|
||||
},
|
||||
{
|
||||
"id": "d3cc9a0f-0a9b-4094-a4a4-af65b6666b18",
|
||||
@@ -47,7 +51,8 @@
|
||||
"views": 7627,
|
||||
"likes": 65,
|
||||
"createdAt": "2026-04-02T01:44:54.863Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.841Z"
|
||||
},
|
||||
{
|
||||
"id": "ff181924-a244-493f-acc4-da5df3c3fbd4",
|
||||
@@ -57,7 +62,8 @@
|
||||
"views": 14244,
|
||||
"likes": 240,
|
||||
"createdAt": "2026-04-02T01:43:43.091Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:18.959Z"
|
||||
},
|
||||
{
|
||||
"id": "02bd9517-38ba-4392-8fa2-e79abc18eded",
|
||||
@@ -67,7 +73,8 @@
|
||||
"views": 19672,
|
||||
"likes": 529,
|
||||
"createdAt": "2026-04-02T01:40:46.648Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.076Z"
|
||||
},
|
||||
{
|
||||
"id": "29c5453f-5a8b-4995-a4a7-c8379e2d50e9",
|
||||
@@ -77,7 +84,8 @@
|
||||
"views": 16421,
|
||||
"likes": 420,
|
||||
"createdAt": "2026-04-02T01:39:56.715Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.186Z"
|
||||
},
|
||||
{
|
||||
"id": "a8016745-d7ae-4fb3-ada9-0573e9fc213b",
|
||||
@@ -87,7 +95,8 @@
|
||||
"views": 22975,
|
||||
"likes": 583,
|
||||
"createdAt": "2026-04-02T01:37:37.687Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.282Z"
|
||||
},
|
||||
{
|
||||
"id": "0d1722d9-c2f6-4d69-8a97-31e5baba1975",
|
||||
@@ -97,7 +106,8 @@
|
||||
"views": 24037,
|
||||
"likes": 83,
|
||||
"createdAt": "2026-04-02T01:36:38.486Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.388Z"
|
||||
},
|
||||
{
|
||||
"id": "8d7c51dc-251c-4849-b053-499e55bbc586",
|
||||
@@ -107,7 +117,8 @@
|
||||
"views": 14891,
|
||||
"likes": 667,
|
||||
"createdAt": "2026-04-02T01:35:16.255Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.507Z"
|
||||
},
|
||||
{
|
||||
"id": "f12eec79-b870-45c7-a74f-35c2a69d84c2",
|
||||
@@ -117,7 +128,8 @@
|
||||
"views": 16201,
|
||||
"likes": 396,
|
||||
"createdAt": "2026-04-01T16:06:40.801Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.627Z"
|
||||
},
|
||||
{
|
||||
"id": "3344f1cc-d547-4df9-9b96-866ab10a10e3",
|
||||
@@ -127,7 +139,8 @@
|
||||
"views": 6862,
|
||||
"likes": 334,
|
||||
"createdAt": "2026-04-01T16:03:52.234Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.764Z"
|
||||
},
|
||||
{
|
||||
"id": "bb28628f-5dbc-458a-9681-35904eb94590",
|
||||
@@ -137,7 +150,8 @@
|
||||
"views": 23256,
|
||||
"likes": 84,
|
||||
"createdAt": "2026-04-01T16:01:12.555Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.876Z"
|
||||
},
|
||||
{
|
||||
"id": "40cdf4d1-62fb-43fb-9891-3e7d4dc3c641",
|
||||
@@ -147,7 +161,8 @@
|
||||
"views": 12044,
|
||||
"likes": 244,
|
||||
"createdAt": "2026-04-01T16:00:02.916Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:19.973Z"
|
||||
},
|
||||
{
|
||||
"id": "82ac512b-f051-42d1-b3cc-bd2f6d92fdb2",
|
||||
@@ -157,7 +172,8 @@
|
||||
"views": 19147,
|
||||
"likes": 521,
|
||||
"createdAt": "2026-04-01T15:58:19.514Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.102Z"
|
||||
},
|
||||
{
|
||||
"id": "e25336ee-9857-47b8-b5da-a87267dc6aad",
|
||||
@@ -167,7 +183,8 @@
|
||||
"views": 10493,
|
||||
"likes": 103,
|
||||
"createdAt": "2026-04-01T15:57:36.080Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.246Z"
|
||||
},
|
||||
{
|
||||
"id": "d6942ee2-97be-4d9d-bfaf-c0fde0558de0",
|
||||
@@ -177,7 +194,8 @@
|
||||
"views": 19836,
|
||||
"likes": 476,
|
||||
"createdAt": "2026-04-01T15:57:05.112Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.351Z"
|
||||
},
|
||||
{
|
||||
"id": "34548b08-2577-4fd9-b428-7deef9aca610",
|
||||
@@ -187,7 +205,8 @@
|
||||
"views": 19599,
|
||||
"likes": 721,
|
||||
"createdAt": "2026-04-01T15:56:37.442Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.470Z"
|
||||
},
|
||||
{
|
||||
"id": "256b1d36-4713-4103-b9d3-8f4e10667288",
|
||||
@@ -197,7 +216,8 @@
|
||||
"views": 22519,
|
||||
"likes": 636,
|
||||
"createdAt": "2026-04-01T15:56:04.431Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.590Z"
|
||||
},
|
||||
{
|
||||
"id": "f77eacef-c66d-4a84-b0f5-2044b6a83b5b",
|
||||
@@ -207,7 +227,8 @@
|
||||
"views": 7870,
|
||||
"likes": 161,
|
||||
"createdAt": "2026-04-01T15:55:32.788Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.742Z"
|
||||
},
|
||||
{
|
||||
"id": "62d7b82f-a078-4f08-af39-e527ba916a01",
|
||||
@@ -217,7 +238,8 @@
|
||||
"views": 13977,
|
||||
"likes": 658,
|
||||
"createdAt": "2026-04-01T15:54:30.908Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.878Z"
|
||||
},
|
||||
{
|
||||
"id": "748ae90a-cc90-4388-ae20-5c18f43c8a4b",
|
||||
@@ -227,7 +249,8 @@
|
||||
"views": 18580,
|
||||
"likes": 63,
|
||||
"createdAt": "2026-04-01T15:54:03.238Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:20.995Z"
|
||||
},
|
||||
{
|
||||
"id": "31f23d44-8403-4a22-bb0f-c8636bd7c447",
|
||||
@@ -237,7 +260,8 @@
|
||||
"views": 13711,
|
||||
"likes": 399,
|
||||
"createdAt": "2026-04-01T15:53:34.082Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.103Z"
|
||||
},
|
||||
{
|
||||
"id": "da6767b8-bca1-4c1f-a18d-70767db6d81c",
|
||||
@@ -247,7 +271,8 @@
|
||||
"views": 21588,
|
||||
"likes": 137,
|
||||
"createdAt": "2026-04-01T15:52:47.322Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.284Z"
|
||||
},
|
||||
{
|
||||
"id": "3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f",
|
||||
@@ -257,7 +282,8 @@
|
||||
"views": 21349,
|
||||
"likes": 484,
|
||||
"createdAt": "2026-04-01T15:51:59.158Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.480Z"
|
||||
},
|
||||
{
|
||||
"id": "ad4ccd5d-4c58-43f1-af72-fda7350bddc7",
|
||||
@@ -267,7 +293,8 @@
|
||||
"views": 21822,
|
||||
"likes": 311,
|
||||
"createdAt": "2026-04-01T15:35:13.690Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.592Z"
|
||||
},
|
||||
{
|
||||
"id": "7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3",
|
||||
@@ -277,7 +304,8 @@
|
||||
"views": 3108,
|
||||
"likes": 51,
|
||||
"createdAt": "2026-04-01T15:34:35.030Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.704Z"
|
||||
},
|
||||
{
|
||||
"id": "2881c592-2827-413b-8f91-270c2f6f6788",
|
||||
@@ -287,7 +315,8 @@
|
||||
"views": 24459,
|
||||
"likes": 695,
|
||||
"createdAt": "2026-04-01T15:33:45.538Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.829Z"
|
||||
},
|
||||
{
|
||||
"id": "cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37",
|
||||
@@ -297,7 +326,8 @@
|
||||
"views": 12483,
|
||||
"likes": 705,
|
||||
"createdAt": "2026-04-01T15:31:53.940Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:21.991Z"
|
||||
},
|
||||
{
|
||||
"id": "e1f817dd-627c-419c-a5ce-7dc7a4b05f64",
|
||||
@@ -307,7 +337,8 @@
|
||||
"views": 5699,
|
||||
"likes": 105,
|
||||
"createdAt": "2026-04-01T15:29:41.289Z",
|
||||
"sourceFormat": "glsl"
|
||||
"sourceFormat": "glsl",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.117Z"
|
||||
},
|
||||
{
|
||||
"id": "07545078-a585-4e2b-b35e-66f75f080bbd",
|
||||
@@ -318,7 +349,8 @@
|
||||
"likes": 195,
|
||||
"createdAt": "2026-04-01T15:25:55.867Z",
|
||||
"sourceFormat": "glsl",
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z"
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.229Z"
|
||||
},
|
||||
{
|
||||
"id": "d90c5b45-858f-41ca-a826-28ab3444188b",
|
||||
@@ -329,7 +361,8 @@
|
||||
"likes": 545,
|
||||
"createdAt": "2026-04-01T15:24:44.569Z",
|
||||
"sourceFormat": "glsl",
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z"
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.372Z"
|
||||
},
|
||||
{
|
||||
"id": "a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff",
|
||||
@@ -340,7 +373,8 @@
|
||||
"likes": 76,
|
||||
"createdAt": "2026-04-01T15:24:08.297Z",
|
||||
"sourceFormat": "glsl",
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z"
|
||||
"updatedAt": "2026-04-01T15:26:50.214Z",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.558Z"
|
||||
},
|
||||
{
|
||||
"id": "ab0cc421-e34d-4244-a540-03f029ec7372",
|
||||
@@ -351,7 +385,8 @@
|
||||
"likes": 487,
|
||||
"createdAt": "2026-04-01T15:17:20.407Z",
|
||||
"sourceFormat": "glsl",
|
||||
"updatedAt": "2026-04-01T15:23:17.765Z"
|
||||
"updatedAt": "2026-04-01T15:23:17.765Z",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.675Z"
|
||||
},
|
||||
{
|
||||
"id": "d5728c14-a698-4a58-94d7-76a81f97cc99",
|
||||
@@ -362,7 +397,8 @@
|
||||
"likes": 171,
|
||||
"createdAt": "2026-04-01T15:06:25.335Z",
|
||||
"sourceFormat": "glsl",
|
||||
"updatedAt": "2026-04-01T15:23:17.766Z"
|
||||
"updatedAt": "2026-04-01T15:23:17.766Z",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.798Z"
|
||||
},
|
||||
{
|
||||
"id": "alien-core-ported",
|
||||
@@ -371,6 +407,7 @@
|
||||
"views": 9754,
|
||||
"likes": 256,
|
||||
"createdAt": "2026-04-01T00:00:00.000Z",
|
||||
"code": "vec3 paletteAC(float d) {\n return mix(vec3(0.2, 0.7, 0.9), vec3(1.0, 0.0, 1.0), d);\n}\n\nvec2 rotateAC(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return mat2(c, -s, s, c) * p;\n}\n\nfloat mapAC(vec3 p) {\n for (int i = 0; i < 8; i++) {\n float t = iTime * 0.2;\n p.xz = rotateAC(p.xz, t);\n p.xy = rotateAC(p.xy, t * 1.89);\n p.xz = abs(p.xz) - 0.5;\n }\n return dot(sign(p), p) / 5.0;\n}\n\nvec4 raymarchAC(vec3 ro, vec3 rd) {\n float t = 0.0;\n vec3 col = vec3(0.0);\n float d = 0.0;\n for (int i = 0; i < 64; i++) {\n vec3 p = ro + rd * t;\n d = mapAC(p) * 0.5;\n if (d < 0.02 || d > 100.0) break;\n col += paletteAC(length(p) * 0.1) / (400.0 * d);\n t += d;\n }\n float alpha = 1.0 / (max(d, 0.0001) * 100.0);\n return vec4(col, alpha);\n}\n\nvoid mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.x;\n vec3 ro = vec3(0.0, 0.0, -50.0);\n ro.xz = rotateAC(ro.xz, iTime);\n\n vec3 cf = normalize(-ro);\n vec3 cs = normalize(cross(cf, vec3(0.0, 1.0, 0.0)));\n vec3 cu = normalize(cross(cf, cs));\n vec3 uuv = ro + cf * 3.0 + uv.x * cs + uv.y * cu;\n vec3 rd = normalize(uuv - ro);\n\n fragColor = raymarchAC(ro, rd);\n}"
|
||||
"code": "vec3 paletteAC(float d) {\n return mix(vec3(0.2, 0.7, 0.9), vec3(1.0, 0.0, 1.0), d);\n}\n\nvec2 rotateAC(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return mat2(c, -s, s, c) * p;\n}\n\nfloat mapAC(vec3 p) {\n for (int i = 0; i < 8; i++) {\n float t = iTime * 0.2;\n p.xz = rotateAC(p.xz, t);\n p.xy = rotateAC(p.xy, t * 1.89);\n p.xz = abs(p.xz) - 0.5;\n }\n return dot(sign(p), p) / 5.0;\n}\n\nvec4 raymarchAC(vec3 ro, vec3 rd) {\n float t = 0.0;\n vec3 col = vec3(0.0);\n float d = 0.0;\n for (int i = 0; i < 64; i++) {\n vec3 p = ro + rd * t;\n d = mapAC(p) * 0.5;\n if (d < 0.02 || d > 100.0) break;\n col += paletteAC(length(p) * 0.1) / (400.0 * d);\n t += d;\n }\n float alpha = 1.0 / (max(d, 0.0001) * 100.0);\n return vec4(col, alpha);\n}\n\nvoid mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.x;\n vec3 ro = vec3(0.0, 0.0, -50.0);\n ro.xz = rotateAC(ro.xz, iTime);\n\n vec3 cf = normalize(-ro);\n vec3 cs = normalize(cross(cf, vec3(0.0, 1.0, 0.0)));\n vec3 cu = normalize(cross(cf, cs));\n vec3 uuv = ro + cf * 3.0 + uv.x * cs + uv.y * cu;\n vec3 rd = normalize(uuv - ro);\n\n fragColor = raymarchAC(ro, rd);\n}",
|
||||
"thumbnailAt": "2026-04-02T02:38:22.908Z"
|
||||
}
|
||||
]
|
||||
BIN
data/thumbnails/02bd9517-38ba-4392-8fa2-e79abc18eded.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
data/thumbnails/07545078-a585-4e2b-b35e-66f75f080bbd.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
data/thumbnails/0d1722d9-c2f6-4d69-8a97-31e5baba1975.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
data/thumbnails/256b1d36-4713-4103-b9d3-8f4e10667288.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
data/thumbnails/2881c592-2827-413b-8f91-270c2f6f6788.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
data/thumbnails/29c5453f-5a8b-4995-a4a7-c8379e2d50e9.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
data/thumbnails/31f23d44-8403-4a22-bb0f-c8636bd7c447.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
data/thumbnails/3344f1cc-d547-4df9-9b96-866ab10a10e3.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
data/thumbnails/34548b08-2577-4fd9-b428-7deef9aca610.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
data/thumbnails/347c3c57-1b24-49bb-9c1c-37b6a4aff0de.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
data/thumbnails/3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
data/thumbnails/40cdf4d1-62fb-43fb-9891-3e7d4dc3c641.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
data/thumbnails/50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
data/thumbnails/62d7b82f-a078-4f08-af39-e527ba916a01.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
data/thumbnails/748ae90a-cc90-4388-ae20-5c18f43c8a4b.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
data/thumbnails/7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
data/thumbnails/82ac512b-f051-42d1-b3cc-bd2f6d92fdb2.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
data/thumbnails/8d7c51dc-251c-4849-b053-499e55bbc586.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
data/thumbnails/a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
data/thumbnails/a8016745-d7ae-4fb3-ada9-0573e9fc213b.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
data/thumbnails/ab0cc421-e34d-4244-a540-03f029ec7372.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
data/thumbnails/ad4ccd5d-4c58-43f1-af72-fda7350bddc7.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
data/thumbnails/alien-core-ported.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
data/thumbnails/b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354.png
Normal file
|
After Width: | Height: | Size: 393 KiB |
BIN
data/thumbnails/bb28628f-5dbc-458a-9681-35904eb94590.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
data/thumbnails/cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
data/thumbnails/d3cc9a0f-0a9b-4094-a4a4-af65b6666b18.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
data/thumbnails/d5728c14-a698-4a58-94d7-76a81f97cc99.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
data/thumbnails/d6942ee2-97be-4d9d-bfaf-c0fde0558de0.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
data/thumbnails/d90c5b45-858f-41ca-a826-28ab3444188b.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
data/thumbnails/da6767b8-bca1-4c1f-a18d-70767db6d81c.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
data/thumbnails/dd9bfa65-1443-45b5-a83b-d25538b2ddbd.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
data/thumbnails/e1f817dd-627c-419c-a5ce-7dc7a4b05f64.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
data/thumbnails/e25336ee-9857-47b8-b5da-a87267dc6aad.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
data/thumbnails/f12eec79-b870-45c7-a74f-35c2a69d84c2.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
data/thumbnails/f77eacef-c66d-4a84-b0f5-2044b6a83b5b.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
data/thumbnails/ff181924-a244-493f-acc4-da5df3c3fbd4.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
657
display.js
@@ -13,11 +13,85 @@ const backBtn = document.getElementById("back-btn");
|
||||
|
||||
const API_BASE = "/api/shaders";
|
||||
|
||||
/** Grid previews: limit fill-rate so many cards can stay near 60fps (detail view uses full quality). */
|
||||
const PREVIEW_MAX_PIXEL_RATIO = 1.25;
|
||||
const PREVIEW_MAX_BUFFER_LONG_SIDE = 520;
|
||||
const DETAIL_MAX_PIXEL_RATIO = 2;
|
||||
const DETAIL_MAX_BUFFER_LONG_SIDE = 2560;
|
||||
/** 串行补全缺失缩略图(每步独立 WebGL,用完即释放),避免多卡片同时建上下文 */
|
||||
const thumbBackfillQueue = [];
|
||||
const thumbBackfillQueuedIds = new Set();
|
||||
const thumbBackfillFailUntil = new Map();
|
||||
let thumbBackfillRunner = false;
|
||||
const THUMB_BACKOFF_MS = 120000;
|
||||
|
||||
let gridHoverRuntime = null;
|
||||
let sharedHoverCanvas = null;
|
||||
|
||||
function scheduleThumbBackfill(shaders) {
|
||||
if (typeof window.captureShaderThumbnail !== "function") return;
|
||||
const now = Date.now();
|
||||
for (const s of shaders) {
|
||||
if (s.thumbnailUrl) continue;
|
||||
const code = s.code && String(s.code).trim();
|
||||
if (!code) continue;
|
||||
const retryAt = thumbBackfillFailUntil.get(s.id);
|
||||
if (retryAt != null && now < retryAt) continue;
|
||||
if (thumbBackfillQueuedIds.has(s.id)) continue;
|
||||
thumbBackfillQueuedIds.add(s.id);
|
||||
thumbBackfillQueue.push({ id: s.id, code, name: s.name || "shader" });
|
||||
}
|
||||
void runThumbBackfillLoop();
|
||||
}
|
||||
|
||||
async function runThumbBackfillLoop() {
|
||||
if (thumbBackfillRunner) return;
|
||||
thumbBackfillRunner = true;
|
||||
try {
|
||||
while (thumbBackfillQueue.length) {
|
||||
if (detailRuntime || gridHoverRuntime) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
continue;
|
||||
}
|
||||
const job = thumbBackfillQueue.shift();
|
||||
try {
|
||||
const dataUrl = await window.captureShaderThumbnail(job.code, job.name);
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(job.id)}/thumbnail`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pngBase64: dataUrl }),
|
||||
});
|
||||
if (res.ok) {
|
||||
thumbBackfillFailUntil.delete(job.id);
|
||||
const url = `${API_BASE}/${encodeURIComponent(job.id)}/thumbnail?t=${Date.now()}`;
|
||||
replaceCardStageWithThumb(job.id, url);
|
||||
} else {
|
||||
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("缩略图自动生成失败", job.id, e);
|
||||
thumbBackfillFailUntil.set(job.id, Date.now() + THUMB_BACKOFF_MS);
|
||||
} finally {
|
||||
thumbBackfillQueuedIds.delete(job.id);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
} finally {
|
||||
thumbBackfillRunner = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCardStageWithThumb(id, url) {
|
||||
if (gridHoverRuntime && gridHoverRuntime.state.id === id) tearDownGridHover();
|
||||
const card = gridEl.querySelector(`[data-shader-id="${CSS.escape(id)}"]`);
|
||||
if (!card) return;
|
||||
const stage = card.querySelector(".card-stage");
|
||||
if (!stage) return;
|
||||
stage.innerHTML = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
|
||||
}
|
||||
|
||||
/** 全屏详情:限制像素量以便稳定 50+ FPS(过高 DPR × 边长会显著拖慢片元着色器) */
|
||||
const DETAIL_MAX_PIXEL_RATIO = 1.35;
|
||||
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
|
||||
|
||||
/** 卡片 hover 实时预览:单例 WebGL,与缩略图同量级分辨率上限 */
|
||||
const HOVER_MAX_PIXEL_RATIO = 1.25;
|
||||
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
|
||||
|
||||
const GL_CONTEXT_OPTS = {
|
||||
alpha: false,
|
||||
@@ -28,12 +102,6 @@ const GL_CONTEXT_OPTS = {
|
||||
desynchronized: true,
|
||||
};
|
||||
|
||||
/** Browsers cap concurrent WebGL contexts (~8); keep grid below so detail view + pool stay safe. */
|
||||
const MAX_GRID_WEBGL_CONTEXTS = 7;
|
||||
const WEBGL_TEARDOWN_DEBOUNCE_MS = 450;
|
||||
|
||||
let gridGlTouchCounter = 0;
|
||||
|
||||
const vertexShaderSource = `#version 300 es
|
||||
precision highp float;
|
||||
layout(location = 0) in vec2 aPosition;
|
||||
@@ -254,19 +322,6 @@ function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
}
|
||||
|
||||
function computePreviewBufferSize(cssW, cssH) {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, PREVIEW_MAX_PIXEL_RATIO);
|
||||
let w = Math.max(1, Math.floor(cssW * dpr));
|
||||
let h = Math.max(1, Math.floor(cssH * dpr));
|
||||
const long = Math.max(w, h);
|
||||
if (long > PREVIEW_MAX_BUFFER_LONG_SIDE) {
|
||||
const s = PREVIEW_MAX_BUFFER_LONG_SIDE / long;
|
||||
w = Math.max(1, Math.floor(w * s));
|
||||
h = Math.max(1, Math.floor(h * s));
|
||||
}
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function computeDetailBufferSize(cssW, cssH) {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, DETAIL_MAX_PIXEL_RATIO);
|
||||
let w = Math.max(1, Math.floor(cssW * dpr));
|
||||
@@ -280,120 +335,21 @@ function computeDetailBufferSize(cssW, cssH) {
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function countGridWebGLContexts() {
|
||||
let n = 0;
|
||||
for (const p of previews) {
|
||||
if (p.gl) n += 1;
|
||||
function computeHoverBufferSize(cssW, cssH) {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, HOVER_MAX_PIXEL_RATIO);
|
||||
let w = Math.max(1, Math.floor(cssW * dpr));
|
||||
let h = Math.max(1, Math.floor(cssH * dpr));
|
||||
const long = Math.max(w, h);
|
||||
if (long > HOVER_MAX_BUFFER_LONG_SIDE) {
|
||||
const s = HOVER_MAX_BUFFER_LONG_SIDE / long;
|
||||
w = Math.max(1, Math.floor(w * s));
|
||||
h = Math.max(1, Math.floor(h * s));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function tearDownPreviewWebGL(state) {
|
||||
if (state._tearDownTimer) {
|
||||
clearTimeout(state._tearDownTimer);
|
||||
state._tearDownTimer = null;
|
||||
}
|
||||
state._staticRendered = false;
|
||||
state._staticDirty = true;
|
||||
if (!state.gl) return;
|
||||
const gl = state.gl;
|
||||
try {
|
||||
if (state.program) gl.deleteProgram(state.program);
|
||||
if (state.vao) gl.deleteVertexArray(state.vao);
|
||||
if (state.vbo) gl.deleteBuffer(state.vbo);
|
||||
if (state.channelTextures) state.channelTextures.forEach((t) => gl.deleteTexture(t));
|
||||
} catch (_) {
|
||||
/* context may already be lost */
|
||||
}
|
||||
const ext = gl.getExtension("WEBGL_lose_context");
|
||||
if (ext) ext.loseContext();
|
||||
state.gl = null;
|
||||
state.program = null;
|
||||
state.vao = null;
|
||||
state.vbo = null;
|
||||
state.channelTextures = null;
|
||||
state.channelUniforms = null;
|
||||
state.uniforms = null;
|
||||
}
|
||||
|
||||
/** Only evict GPU for cards that are not visible / filtered out — avoids thrash when many cards are on-screen. */
|
||||
function evictOffscreenGridWebGLContext() {
|
||||
const off = previews.filter((p) => p.gl && (!p.visible || p.card.style.display === "none"));
|
||||
if (!off.length) return false;
|
||||
off.sort((a, b) => (a._glTouchSeq || 0) - (b._glTouchSeq || 0));
|
||||
tearDownPreviewWebGL(off[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensurePreviewWebGL(state) {
|
||||
if (state.gl || state.compileFailed) return;
|
||||
const errorEl = state.card.querySelector(".error");
|
||||
while (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) {
|
||||
if (!evictOffscreenGridWebGLContext()) break;
|
||||
}
|
||||
if (countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) {
|
||||
return;
|
||||
}
|
||||
const gl = state.canvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) {
|
||||
errorEl.textContent = "当前浏览器不支持 WebGL2";
|
||||
state.compileFailed = true;
|
||||
return;
|
||||
}
|
||||
let program;
|
||||
let codeForRender = state.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
errorEl.textContent = "";
|
||||
} catch (_err1) {
|
||||
try {
|
||||
codeForRender = toPortableGlsl(state.code);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
errorEl.textContent = "";
|
||||
} catch (_err2) {
|
||||
codeForRender = fallbackShader(state.name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
errorEl.textContent = "";
|
||||
} catch (err3) {
|
||||
errorEl.textContent = `编译失败:\n${String(err3.message || err3)}`;
|
||||
state.compileFailed = true;
|
||||
const ext = gl.getExtension("WEBGL_lose_context");
|
||||
if (ext) ext.loseContext();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.codeForRender = codeForRender;
|
||||
const { vao, vbo } = createQuad(gl);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 128);
|
||||
const channelUniforms = [
|
||||
gl.getUniformLocation(program, "iChannel0"),
|
||||
gl.getUniformLocation(program, "iChannel1"),
|
||||
gl.getUniformLocation(program, "iChannel2"),
|
||||
gl.getUniformLocation(program, "iChannel3"),
|
||||
];
|
||||
const uniforms = {
|
||||
iResolution: gl.getUniformLocation(program, "iResolution"),
|
||||
iTime: gl.getUniformLocation(program, "iTime"),
|
||||
iTimeDelta: gl.getUniformLocation(program, "iTimeDelta"),
|
||||
iFrame: gl.getUniformLocation(program, "iFrame"),
|
||||
iMouse: gl.getUniformLocation(program, "iMouse"),
|
||||
iDate: gl.getUniformLocation(program, "iDate"),
|
||||
};
|
||||
state.gl = gl;
|
||||
state.program = program;
|
||||
state.vao = vao;
|
||||
state.vbo = vbo;
|
||||
state.channelTextures = channelTextures;
|
||||
state.channelUniforms = channelUniforms;
|
||||
state.uniforms = uniforms;
|
||||
state._glTouchSeq = ++gridGlTouchCounter;
|
||||
state._staticDirty = true;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
const previews = [];
|
||||
/** Driven by scheduleRender(); only runs while detail / hover / pending static thumbs need a frame. */
|
||||
/** Driven by scheduleRender(); detail + grid hover. */
|
||||
let renderRafId = 0;
|
||||
|
||||
function scheduleRender() {
|
||||
@@ -404,33 +360,10 @@ function scheduleRender() {
|
||||
});
|
||||
}
|
||||
|
||||
function shouldKeepAnimatingGrid() {
|
||||
for (const p of previews) {
|
||||
if (p.card.style.display === "none") continue;
|
||||
if (!p.visible) continue;
|
||||
if (p.isPointerOver) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function needsStaticThumbnailPass() {
|
||||
for (const p of previews) {
|
||||
if (p.card.style.display === "none") continue;
|
||||
if (!p.visible) continue;
|
||||
if (p.isPointerOver) continue;
|
||||
if (p.compileFailed) continue;
|
||||
if (p._staticRendered && !p._staticDirty) continue;
|
||||
if (!p.gl && countGridWebGLContexts() >= MAX_GRID_WEBGL_CONTEXTS) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldScheduleNextFrame() {
|
||||
if (detailRuntime) return true;
|
||||
if (shouldKeepAnimatingGrid()) return true;
|
||||
if (needsStaticThumbnailPass()) return true;
|
||||
return false;
|
||||
if (gridHoverRuntime) return true;
|
||||
return !paused;
|
||||
}
|
||||
|
||||
let paused = false;
|
||||
@@ -447,150 +380,188 @@ let detailRuntime = null;
|
||||
let searchKeyword = "";
|
||||
let lastSignature = "";
|
||||
|
||||
function getSharedHoverCanvas() {
|
||||
if (!sharedHoverCanvas) {
|
||||
sharedHoverCanvas = document.createElement("canvas");
|
||||
sharedHoverCanvas.className = "card-hover-canvas";
|
||||
}
|
||||
return sharedHoverCanvas;
|
||||
}
|
||||
|
||||
function tearDownGridHover() {
|
||||
if (!gridHoverRuntime) {
|
||||
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
|
||||
const stage = sharedHoverCanvas.parentNode;
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
el.style.visibility = "";
|
||||
});
|
||||
sharedHoverCanvas.remove();
|
||||
sharedHoverCanvas.style.display = "none";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { gl, program, vao, vbo, channelTextures, _hoverResizeObserver } = gridHoverRuntime;
|
||||
if (_hoverResizeObserver) _hoverResizeObserver.disconnect();
|
||||
try {
|
||||
if (program) gl.deleteProgram(program);
|
||||
if (vao) gl.deleteVertexArray(vao);
|
||||
if (vbo) gl.deleteBuffer(vbo);
|
||||
if (channelTextures) channelTextures.forEach((t) => gl.deleteTexture(t));
|
||||
} catch (_) {
|
||||
/* lost */
|
||||
}
|
||||
gridHoverRuntime = null;
|
||||
if (sharedHoverCanvas && sharedHoverCanvas.parentNode) {
|
||||
const stage = sharedHoverCanvas.parentNode;
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
el.style.visibility = "";
|
||||
});
|
||||
sharedHoverCanvas.remove();
|
||||
}
|
||||
if (sharedHoverCanvas) sharedHoverCanvas.style.display = "none";
|
||||
}
|
||||
|
||||
function startGridHover(previewState, card) {
|
||||
if (detailViewEl.classList.contains("active")) return;
|
||||
tearDownGridHover();
|
||||
const canvas = getSharedHoverCanvas();
|
||||
const stage = card.querySelector(".card-stage");
|
||||
if (!stage) return;
|
||||
|
||||
const gl = canvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) return;
|
||||
|
||||
let program;
|
||||
let codeForRender = previewState.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e1) {
|
||||
try {
|
||||
codeForRender = toPortableGlsl(previewState.code);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e2) {
|
||||
codeForRender = fallbackShader(previewState.name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { vao, vbo } = createQuad(gl);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 128);
|
||||
const channelUniforms = [
|
||||
gl.getUniformLocation(program, "iChannel0"),
|
||||
gl.getUniformLocation(program, "iChannel1"),
|
||||
gl.getUniformLocation(program, "iChannel2"),
|
||||
gl.getUniformLocation(program, "iChannel3"),
|
||||
];
|
||||
const uniforms = {
|
||||
iResolution: gl.getUniformLocation(program, "iResolution"),
|
||||
iTime: gl.getUniformLocation(program, "iTime"),
|
||||
iTimeDelta: gl.getUniformLocation(program, "iTimeDelta"),
|
||||
iFrame: gl.getUniformLocation(program, "iFrame"),
|
||||
iMouse: gl.getUniformLocation(program, "iMouse"),
|
||||
iDate: gl.getUniformLocation(program, "iDate"),
|
||||
};
|
||||
|
||||
stage.appendChild(canvas);
|
||||
canvas.style.display = "block";
|
||||
stage.querySelectorAll(":scope > *").forEach((el) => {
|
||||
if (el !== canvas) el.style.visibility = "hidden";
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!gridHoverRuntime) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
gridHoverRuntime._layoutW = r.width;
|
||||
gridHoverRuntime._layoutH = r.height;
|
||||
});
|
||||
ro.observe(canvas);
|
||||
const r0 = canvas.getBoundingClientRect();
|
||||
gridHoverRuntime = {
|
||||
gl,
|
||||
program,
|
||||
vao,
|
||||
vbo,
|
||||
channelTextures,
|
||||
channelUniforms,
|
||||
uniforms,
|
||||
canvas,
|
||||
state: previewState,
|
||||
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
|
||||
localTime: 0.05,
|
||||
localFrame: 1,
|
||||
lastTick: performance.now(),
|
||||
_layoutW: r0.width,
|
||||
_layoutH: r0.height,
|
||||
_hoverResizeObserver: ro,
|
||||
};
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function createPreviewCard(shader) {
|
||||
const { id, name, author = "unknown", views = 0, likes = 0, code } = shader;
|
||||
const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader;
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
card.dataset.search = `${name} ${author}`.toLowerCase();
|
||||
|
||||
const visualInner = thumbnailUrl
|
||||
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
|
||||
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
|
||||
|
||||
card.dataset.shaderId = id;
|
||||
card.innerHTML = `
|
||||
<div class="card-head">
|
||||
<strong>${name}</strong>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button type="button" class="pause-local-btn">暂停</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas></canvas>
|
||||
<div class="card-stage">${visualInner}</div>
|
||||
<div class="card-meta">
|
||||
<span>by ${author}</span>
|
||||
<span>views ${views} | likes ${likes}</span>
|
||||
</div>
|
||||
<div class="card-head error"></div>
|
||||
`;
|
||||
|
||||
const canvas = card.querySelector("canvas");
|
||||
const errorEl = card.querySelector(".error");
|
||||
const pauseLocalBtn = card.querySelector(".pause-local-btn");
|
||||
|
||||
const state = {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
codeForRender: code,
|
||||
compileFailed: false,
|
||||
card,
|
||||
canvas,
|
||||
gl: null,
|
||||
program: null,
|
||||
vao: null,
|
||||
vbo: null,
|
||||
channelTextures: null,
|
||||
channelUniforms: null,
|
||||
uniforms: null,
|
||||
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
|
||||
isPlaying: true,
|
||||
localTime: 0,
|
||||
localFrame: 0,
|
||||
lastTick: performance.now(),
|
||||
visible: false,
|
||||
_layoutW: 0,
|
||||
_layoutH: 0,
|
||||
_glTouchSeq: 0,
|
||||
_tearDownTimer: null,
|
||||
isPointerOver: false,
|
||||
_staticRendered: false,
|
||||
_staticDirty: true,
|
||||
};
|
||||
|
||||
card.addEventListener("click", () => openDetail(state));
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
state.isPointerOver = true;
|
||||
state.lastTick = performance.now();
|
||||
scheduleRender();
|
||||
if (detailViewEl.classList.contains("active")) return;
|
||||
startGridHover(state, card);
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
state.isPointerOver = false;
|
||||
state.localTime = 0;
|
||||
state.localFrame = 0;
|
||||
state._staticDirty = true;
|
||||
state._staticRendered = false;
|
||||
scheduleRender();
|
||||
if (gridHoverRuntime && gridHoverRuntime.state.id === state.id) tearDownGridHover();
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const bw = canvas.width || Math.max(1, state._layoutW || rect.width);
|
||||
const bh = canvas.height || Math.max(1, state._layoutH || rect.height);
|
||||
state.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
|
||||
state.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
|
||||
card.addEventListener("mousemove", (event) => {
|
||||
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
|
||||
const c = gridHoverRuntime.canvas;
|
||||
const rect = c.getBoundingClientRect();
|
||||
const bw = c.width || 1;
|
||||
const bh = c.height || 1;
|
||||
gridHoverRuntime.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
|
||||
gridHoverRuntime.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
|
||||
});
|
||||
canvas.addEventListener("mousedown", () => {
|
||||
state.mouse.down = true;
|
||||
state.mouse.downX = state.mouse.x;
|
||||
state.mouse.downY = state.mouse.y;
|
||||
card.addEventListener("mousedown", () => {
|
||||
if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
|
||||
gridHoverRuntime.mouse.down = true;
|
||||
gridHoverRuntime.mouse.downX = gridHoverRuntime.mouse.x;
|
||||
gridHoverRuntime.mouse.downY = gridHoverRuntime.mouse.y;
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
state.mouse.down = false;
|
||||
});
|
||||
|
||||
pauseLocalBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
state.isPlaying = !state.isPlaying;
|
||||
state.lastTick = performance.now();
|
||||
pauseLocalBtn.textContent = state.isPlaying ? "暂停" : "继续";
|
||||
scheduleRender();
|
||||
});
|
||||
canvas.addEventListener("click", () => openDetail(state));
|
||||
|
||||
gridEl.appendChild(card);
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target !== canvas) continue;
|
||||
const cr = entry.contentRect;
|
||||
state._layoutW = cr.width;
|
||||
state._layoutH = cr.height;
|
||||
state._staticDirty = true;
|
||||
scheduleRender();
|
||||
}
|
||||
});
|
||||
ro.observe(canvas);
|
||||
state._resizeObserver = ro;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.target !== canvas) continue;
|
||||
state.visible = e.isIntersecting;
|
||||
if (e.isIntersecting) {
|
||||
if (state._tearDownTimer) {
|
||||
clearTimeout(state._tearDownTimer);
|
||||
state._tearDownTimer = null;
|
||||
}
|
||||
state._staticDirty = true;
|
||||
state._staticRendered = false;
|
||||
ensurePreviewWebGL(state);
|
||||
scheduleRender();
|
||||
} else {
|
||||
if (state._tearDownTimer) clearTimeout(state._tearDownTimer);
|
||||
state._tearDownTimer = setTimeout(() => {
|
||||
state._tearDownTimer = null;
|
||||
if (!state.visible) tearDownPreviewWebGL(state);
|
||||
}, WEBGL_TEARDOWN_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: "120px", threshold: 0.01 }
|
||||
);
|
||||
io.observe(canvas);
|
||||
state._io = io;
|
||||
|
||||
previews.push(state);
|
||||
}
|
||||
|
||||
function clearPreviews() {
|
||||
tearDownGridHover();
|
||||
previews.forEach((preview) => {
|
||||
tearDownPreviewWebGL(preview);
|
||||
if (preview._resizeObserver) preview._resizeObserver.disconnect();
|
||||
if (preview._io) preview._io.disconnect();
|
||||
preview.card.remove();
|
||||
});
|
||||
previews.length = 0;
|
||||
@@ -610,19 +581,30 @@ function closeDetail() {
|
||||
}
|
||||
|
||||
function openDetail(previewState) {
|
||||
tearDownGridHover();
|
||||
closeDetail();
|
||||
const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) return;
|
||||
let program;
|
||||
const detailCode = previewState.codeForRender || previewState.code;
|
||||
let detailCode = previewState.code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
} catch (_e1) {
|
||||
try {
|
||||
detailCode = toPortableGlsl(previewState.code);
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (_e2) {
|
||||
detailCode = fallbackShader(previewState.name);
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(detailCode));
|
||||
} catch (err) {
|
||||
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { vao, vbo } = createQuad(gl);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 256);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 128);
|
||||
const channelUniforms = [
|
||||
gl.getUniformLocation(program, "iChannel0"),
|
||||
gl.getUniformLocation(program, "iChannel1"),
|
||||
@@ -679,7 +661,12 @@ async function loadShaders() {
|
||||
if (!res.ok) throw new Error("加载失败");
|
||||
const shaders = await res.json();
|
||||
const signature = JSON.stringify(
|
||||
shaders.map((s) => ({ id: s.id, updatedAt: s.updatedAt || s.createdAt || "", name: s.name }))
|
||||
shaders.map((s) => ({
|
||||
id: s.id,
|
||||
updatedAt: s.updatedAt || s.createdAt || "",
|
||||
thumbnailAt: s.thumbnailAt || "",
|
||||
name: s.name,
|
||||
}))
|
||||
);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
@@ -688,6 +675,7 @@ async function loadShaders() {
|
||||
applySearch();
|
||||
syncStats();
|
||||
}
|
||||
scheduleThumbBackfill(shaders);
|
||||
} catch (err) {
|
||||
statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
|
||||
}
|
||||
@@ -716,83 +704,56 @@ function renderAll(ts) {
|
||||
fpsCounter += 1;
|
||||
}
|
||||
|
||||
const detailOpen = !!detailRuntime;
|
||||
|
||||
for (const preview of previews) {
|
||||
let localDt = 0;
|
||||
if (!detailOpen) {
|
||||
const now = performance.now();
|
||||
localDt = Math.min((now - preview.lastTick) / 1000, 0.05);
|
||||
preview.lastTick = now;
|
||||
if (preview.isPointerOver && preview.isPlaying && !paused) {
|
||||
preview.localTime += localDt;
|
||||
preview.localFrame += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!detailOpen &&
|
||||
preview.card.style.display !== "none" &&
|
||||
preview.visible &&
|
||||
!preview.gl &&
|
||||
!preview.compileFailed
|
||||
) {
|
||||
ensurePreviewWebGL(preview);
|
||||
}
|
||||
|
||||
if (detailOpen) continue;
|
||||
if (preview.card.style.display === "none") continue;
|
||||
if (preview.visible === false) continue;
|
||||
if (!preview.gl) continue;
|
||||
|
||||
const isAnim = preview.isPointerOver;
|
||||
if (!isAnim && preview._staticRendered && !preview._staticDirty) continue;
|
||||
|
||||
const { gl, canvas, program, vao, uniforms, mouse, channelTextures, channelUniforms } = preview;
|
||||
if (!preview._layoutW || !preview._layoutH) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
preview._layoutW = r.width;
|
||||
preview._layoutH = r.height;
|
||||
}
|
||||
const { w, h } = computePreviewBufferSize(preview._layoutW, preview._layoutH);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
if (!isAnim) preview._staticDirty = true;
|
||||
}
|
||||
gl.useProgram(program);
|
||||
bindChannelTextures(gl, channelUniforms, channelTextures);
|
||||
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
|
||||
if (isAnim) {
|
||||
gl.uniform1f(uniforms.iTime, preview.localTime);
|
||||
gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0);
|
||||
gl.uniform1i(uniforms.iFrame, preview.localFrame);
|
||||
if (gridHoverRuntime) {
|
||||
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms, canvas } =
|
||||
gridHoverRuntime;
|
||||
if (gl.isContextLost()) {
|
||||
tearDownGridHover();
|
||||
} else {
|
||||
gl.uniform1f(uniforms.iTime, 0);
|
||||
gl.uniform1f(uniforms.iTimeDelta, 0);
|
||||
gl.uniform1i(uniforms.iFrame, 0);
|
||||
}
|
||||
setIDateUniform(gl, uniforms.iDate);
|
||||
gl.uniform4f(
|
||||
uniforms.iMouse,
|
||||
mouse.x,
|
||||
mouse.y,
|
||||
mouse.down ? mouse.downX : -mouse.downX,
|
||||
mouse.down ? mouse.downY : -mouse.downY
|
||||
);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
preview._glTouchSeq = ++gridGlTouchCounter;
|
||||
if (!isAnim) {
|
||||
preview._staticRendered = true;
|
||||
preview._staticDirty = false;
|
||||
const now = performance.now();
|
||||
const localDt = Math.min((now - gridHoverRuntime.lastTick) / 1000, 0.05);
|
||||
gridHoverRuntime.lastTick = now;
|
||||
if (!paused) {
|
||||
gridHoverRuntime.localTime += localDt;
|
||||
gridHoverRuntime.localFrame += 1;
|
||||
}
|
||||
const lw =
|
||||
gridHoverRuntime._layoutW > 0 ? gridHoverRuntime._layoutW : canvas.clientWidth || 1;
|
||||
const lh =
|
||||
gridHoverRuntime._layoutH > 0 ? gridHoverRuntime._layoutH : canvas.clientHeight || 1;
|
||||
const { w, h } = computeHoverBufferSize(lw, lh);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
gl.useProgram(program);
|
||||
bindChannelTextures(gl, channelUniforms, channelTextures);
|
||||
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
|
||||
gl.uniform1f(uniforms.iTime, gridHoverRuntime.localTime);
|
||||
gl.uniform1f(uniforms.iTimeDelta, paused ? 0 : localDt);
|
||||
gl.uniform1i(uniforms.iFrame, gridHoverRuntime.localFrame);
|
||||
setIDateUniform(gl, uniforms.iDate);
|
||||
gl.uniform4f(
|
||||
uniforms.iMouse,
|
||||
mouse.x,
|
||||
mouse.y,
|
||||
mouse.down ? mouse.downX : -mouse.downX,
|
||||
mouse.down ? mouse.downY : -mouse.downY
|
||||
);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (detailRuntime) {
|
||||
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime;
|
||||
if (gl.isContextLost()) {
|
||||
closeDetail();
|
||||
scheduleRender();
|
||||
return;
|
||||
}
|
||||
const lw =
|
||||
detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1;
|
||||
const lh =
|
||||
@@ -866,8 +827,8 @@ detailCanvas.addEventListener("mousedown", () => {
|
||||
detailRuntime.mouse.downY = detailRuntime.mouse.y;
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!detailRuntime) return;
|
||||
detailRuntime.mouse.down = false;
|
||||
if (detailRuntime) detailRuntime.mouse.down = false;
|
||||
if (gridHoverRuntime) gridHoverRuntime.mouse.down = false;
|
||||
});
|
||||
searchInput.addEventListener("input", (event) => {
|
||||
searchKeyword = event.target.value || "";
|
||||
|
||||
40
index.html
@@ -160,11 +160,42 @@
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.card canvas {
|
||||
.card-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
display: block;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-hover-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-thumb--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #6a7088;
|
||||
background: linear-gradient(145deg, #0a0c12 0%, #12151f 100%);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
@@ -304,9 +335,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats">准备就绪</div>
|
||||
<p class="view-hint">
|
||||
列表默认仅显示首帧静态预览;鼠标悬停在卡片上时才会持续渲染动画。点击进入全屏仍为实时播放。
|
||||
</p>
|
||||
|
||||
<section id="preview-grid" class="preview-grid"></section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -324,6 +353,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="./thumb-renderer.js"></script>
|
||||
<script src="./display.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
107
server.js
@@ -6,8 +6,9 @@ const crypto = require("crypto");
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5180;
|
||||
const DB_PATH = path.join(__dirname, "data", "shaders.json");
|
||||
const THUMB_DIR = path.join(__dirname, "data", "thumbnails");
|
||||
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.json({ limit: "4mb" }));
|
||||
app.use(express.static(__dirname));
|
||||
|
||||
async function ensureDb() {
|
||||
@@ -19,6 +20,50 @@ async function ensureDb() {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureThumbnailsDir() {
|
||||
await fs.mkdir(THUMB_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function thumbPath(id) {
|
||||
return path.join(THUMB_DIR, `${id}.png`);
|
||||
}
|
||||
|
||||
async function thumbFileExists(id) {
|
||||
try {
|
||||
await fs.access(thumbPath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodePngBase64(s) {
|
||||
if (!s || typeof s !== "string") return null;
|
||||
const t = s.trim();
|
||||
const m = /^data:image\/png;base64,(.+)$/i.exec(t);
|
||||
const b64 = m ? m[1] : t.replace(/\s/g, "");
|
||||
try {
|
||||
return Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPngBuffer(buf) {
|
||||
return (
|
||||
buf &&
|
||||
buf.length >= 8 &&
|
||||
buf[0] === 0x89 &&
|
||||
buf[1] === 0x50 &&
|
||||
buf[2] === 0x4e &&
|
||||
buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d &&
|
||||
buf[5] === 0x0a &&
|
||||
buf[6] === 0x1a &&
|
||||
buf[7] === 0x0a
|
||||
);
|
||||
}
|
||||
|
||||
async function readDb() {
|
||||
await ensureDb();
|
||||
const raw = await fs.readFile(DB_PATH, "utf8");
|
||||
@@ -286,6 +331,7 @@ async function autoNormalizeStoredShaders() {
|
||||
|
||||
app.get("/api/shaders", async (_req, res) => {
|
||||
try {
|
||||
await ensureThumbnailsDir();
|
||||
let shaders = await readDb();
|
||||
// Runtime safeguard: always return normalized code for display layer.
|
||||
shaders = shaders.map((item) => {
|
||||
@@ -297,7 +343,16 @@ app.get("/api/shaders", async (_req, res) => {
|
||||
if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted";
|
||||
return next;
|
||||
});
|
||||
res.json(shaders);
|
||||
const out = await Promise.all(
|
||||
shaders.map(async (item) => {
|
||||
const has = await thumbFileExists(item.id);
|
||||
const thumbnailUrl = has
|
||||
? `/api/shaders/${encodeURIComponent(item.id)}/thumbnail`
|
||||
: null;
|
||||
return { ...item, thumbnailUrl };
|
||||
})
|
||||
);
|
||||
res.json(out);
|
||||
} catch {
|
||||
res.status(500).json({ error: "读取失败" });
|
||||
}
|
||||
@@ -333,6 +388,48 @@ app.post("/api/shaders", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/shaders/:id/thumbnail", async (req, res) => {
|
||||
try {
|
||||
const p = thumbPath(req.params.id);
|
||||
await fs.access(p);
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.sendFile(path.resolve(p));
|
||||
} catch {
|
||||
res.status(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/shaders/:id/thumbnail", async (req, res) => {
|
||||
const { pngBase64 } = req.body || {};
|
||||
const buf = decodePngBase64(pngBase64);
|
||||
if (!buf || buf.length < 32) {
|
||||
return res.status(400).json({ error: "无效 PNG 数据" });
|
||||
}
|
||||
if (!isPngBuffer(buf)) {
|
||||
return res.status(400).json({ error: "必须是 PNG" });
|
||||
}
|
||||
if (buf.length > 2 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: "缩略图过大" });
|
||||
}
|
||||
try {
|
||||
const shaders = await readDb();
|
||||
const idx = shaders.findIndex((it) => it.id === req.params.id);
|
||||
if (idx < 0) {
|
||||
return res.status(404).json({ error: "未找到该 shader" });
|
||||
}
|
||||
await ensureThumbnailsDir();
|
||||
await fs.writeFile(thumbPath(req.params.id), buf);
|
||||
const thumbnailAt = new Date().toISOString();
|
||||
shaders[idx] = { ...shaders[idx], thumbnailAt };
|
||||
await writeDb(shaders);
|
||||
const thumbnailUrl = `/api/shaders/${encodeURIComponent(req.params.id)}/thumbnail`;
|
||||
res.json({ ok: true, thumbnailUrl, thumbnailAt });
|
||||
} catch {
|
||||
res.status(500).json({ error: "保存缩略图失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/shaders/:id", async (req, res) => {
|
||||
try {
|
||||
const shaders = await readDb();
|
||||
@@ -341,6 +438,12 @@ app.delete("/api/shaders/:id", async (req, res) => {
|
||||
return res.status(404).json({ error: "未找到该 shader" });
|
||||
}
|
||||
await writeDb(next);
|
||||
await ensureThumbnailsDir();
|
||||
try {
|
||||
await fs.unlink(thumbPath(req.params.id));
|
||||
} catch (_) {
|
||||
/* no thumb */
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch {
|
||||
res.status(500).json({ error: "删除失败" });
|
||||
|
||||
362
thumb-renderer.js
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* One-off WebGL capture: try multiple iTime values, pick a non-degenerate frame (avoid flat black/white),
|
||||
* export PNG, then loseContext.
|
||||
*/
|
||||
(function () {
|
||||
const THUMB_W = 512;
|
||||
const THUMB_H = 320;
|
||||
|
||||
/** 首帧常为过黑/过白/无对比时,在这些时刻采样并打分选帧 */
|
||||
const THUMB_TIME_CANDIDATES = [
|
||||
0, 0.1, 0.22, 0.45, 0.78, 1.15, 1.7, 2.25, 2.85, 3.45, 4.2, 5.1, 6.28, 7.5, 9, 11, 13,
|
||||
];
|
||||
|
||||
const GL_CONTEXT_OPTS = {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
powerPreference: "high-performance",
|
||||
desynchronized: true,
|
||||
};
|
||||
|
||||
const vertexShaderSource = `#version 300 es
|
||||
precision highp float;
|
||||
layout(location = 0) in vec2 aPosition;
|
||||
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
|
||||
|
||||
function toPortableGlsl(code) {
|
||||
if (!code) return "";
|
||||
let s = String(code);
|
||||
const start = s.indexOf("void mainImage");
|
||||
if (start >= 0) {
|
||||
s = s.slice(0, s.length);
|
||||
}
|
||||
s = s
|
||||
.replace(/float2/g, "vec2")
|
||||
.replace(/float3/g, "vec3")
|
||||
.replace(/float4/g, "vec4")
|
||||
.replace(/int2/g, "ivec2")
|
||||
.replace(/int3/g, "ivec3")
|
||||
.replace(/int4/g, "ivec4")
|
||||
.replace(/precise::tanh/g, "tanh")
|
||||
.replace(/ANGLE_userUniforms\._uiResolution/g, "iResolution")
|
||||
.replace(/ANGLE_userUniforms\._uiTime/g, "iTime")
|
||||
.replace(/ANGLE_userUniforms\._uiMouse/g, "iMouse")
|
||||
.replace(/ANGLE_texture\([^)]*\)/g, "vec4(0.0)")
|
||||
.replace(/ANGLE_texelFetch\([^)]*\)/g, "vec4(0.0)")
|
||||
.replace(/ANGLE_mod\(/g, "mod(")
|
||||
.replace(/\[\[[^\]]+\]\]/g, "")
|
||||
.replace(/template\s*[^\n]*\n/g, "")
|
||||
.replace(/ANGLE_out\(([^)]+)\)/g, "$1")
|
||||
.replace(/ANGLE_swizzle_ref\(([^)]+)\)/g, "$1")
|
||||
.replace(/ANGLE_elem_ref\(([^)]+)\)/g, "$1")
|
||||
.replace(/(\d+\.\d+|\d+)f\b/g, "$1");
|
||||
|
||||
const mi = s.indexOf("void mainImage");
|
||||
if (mi >= 0) {
|
||||
const head = s.slice(0, mi);
|
||||
const helpers = head
|
||||
.split("\n\n")
|
||||
.filter((blk) => /(void|float|vec[234]|mat[234])\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(blk))
|
||||
.join("\n\n");
|
||||
const mainPart = s.slice(mi);
|
||||
return `${helpers}\n\n${mainPart}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function fallbackShader(name) {
|
||||
const h = (name || "shader")
|
||||
.split("")
|
||||
.reduce((a, c) => (a * 33 + c.charCodeAt(0)) >>> 0, 5381);
|
||||
const k1 = ((h % 97) / 97).toFixed(3);
|
||||
const k2 = (((h >> 5) % 89) / 89).toFixed(3);
|
||||
return `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||
float t = iTime;
|
||||
float v = sin(uv.x * (8.0 + ${k1} * 8.0) + t * (1.4 + ${k2} * 1.2))
|
||||
+ cos(uv.y * (10.0 + ${k2} * 6.0) - t * (1.1 + ${k1}));
|
||||
vec3 col = 0.5 + 0.5 * cos(vec3(0.2, 1.2, 2.2) + v + t);
|
||||
fragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
}
|
||||
|
||||
function buildFragmentShader(userCode) {
|
||||
return `#version 300 es
|
||||
precision highp float;
|
||||
out vec4 outColor;
|
||||
uniform vec3 iResolution;
|
||||
uniform float iTime;
|
||||
uniform float iTimeDelta;
|
||||
uniform int iFrame;
|
||||
uniform vec4 iMouse;
|
||||
uniform vec4 iDate;
|
||||
uniform sampler2D iChannel0;
|
||||
uniform sampler2D iChannel1;
|
||||
uniform sampler2D iChannel2;
|
||||
uniform sampler2D iChannel3;
|
||||
${userCode}
|
||||
void main() { mainImage(outColor, gl_FragCoord.xy); }`;
|
||||
}
|
||||
|
||||
function setIDateUniform(gl, loc) {
|
||||
if (loc == null) return;
|
||||
const d = new Date();
|
||||
gl.uniform4f(
|
||||
loc,
|
||||
d.getFullYear(),
|
||||
d.getMonth() + 1,
|
||||
d.getDate(),
|
||||
d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() * 0.001
|
||||
);
|
||||
}
|
||||
|
||||
function createQuad(gl) {
|
||||
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
|
||||
const vao = gl.createVertexArray();
|
||||
const vbo = gl.createBuffer();
|
||||
gl.bindVertexArray(vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
return { vao, vbo };
|
||||
}
|
||||
|
||||
function compileProgram(gl, fragmentShaderSource) {
|
||||
function compile(type, label, source) {
|
||||
if (gl.isContextLost?.()) {
|
||||
throw new Error(`${label}: WebGL context lost`);
|
||||
}
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
let info = (gl.getShaderInfoLog(shader) || "").trim();
|
||||
if (!info) {
|
||||
const err = gl.getError();
|
||||
info =
|
||||
err && err !== gl.NO_ERROR
|
||||
? `WebGL getError 0x${err.toString(16)} (driver returned no compile log)`
|
||||
: "No compile log (shader too complex or driver limits).";
|
||||
}
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`${label} shader:\n${info}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
const vs = compile(gl.VERTEX_SHADER, "Vertex", vertexShaderSource);
|
||||
const fs = compile(gl.FRAGMENT_SHADER, "Fragment", fragmentShaderSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
let info = (gl.getProgramInfoLog(program) || "").trim();
|
||||
if (!info) {
|
||||
const err = gl.getError();
|
||||
info =
|
||||
err && err !== gl.NO_ERROR
|
||||
? `WebGL getError 0x${err.toString(16)} (no link log)`
|
||||
: "No link log.";
|
||||
}
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(`Program link:\n${info}`);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTexture(gl, width, height, data) {
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
return tex;
|
||||
}
|
||||
|
||||
function createDefaultChannelTextures(gl, noiseSize) {
|
||||
const w0 = 512;
|
||||
const h0 = 2;
|
||||
const d0 = new Uint8Array(w0 * h0 * 4);
|
||||
for (let x = 0; x < w0; x++) {
|
||||
const t = x / (w0 - 1);
|
||||
const fft = Math.floor(255 * Math.pow(1.0 - t, 1.8));
|
||||
const wave = Math.floor(128 + 127 * Math.sin(t * Math.PI * 10.0));
|
||||
const i0 = (x + 0 * w0) * 4;
|
||||
const i1 = (x + 1 * w0) * 4;
|
||||
d0[i0] = fft;
|
||||
d0[i0 + 1] = fft;
|
||||
d0[i0 + 2] = fft;
|
||||
d0[i0 + 3] = 255;
|
||||
d0[i1] = wave;
|
||||
d0[i1 + 1] = wave;
|
||||
d0[i1 + 2] = wave;
|
||||
d0[i1 + 3] = 255;
|
||||
}
|
||||
|
||||
const size = noiseSize;
|
||||
const makeNoise = (scaleA, scaleB) => {
|
||||
const d = new Uint8Array(size * size * 4);
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
const n = Math.floor(
|
||||
127 +
|
||||
127 *
|
||||
Math.sin((x * scaleA + y * scaleB) * 0.11) *
|
||||
Math.cos((x * scaleB - y * scaleA) * 0.07)
|
||||
);
|
||||
d[i] = n;
|
||||
d[i + 1] = (n * 37) & 255;
|
||||
d[i + 2] = (n * 73) & 255;
|
||||
d[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
return [
|
||||
createTexture(gl, w0, h0, d0),
|
||||
createTexture(gl, size, size, makeNoise(1.0, 0.7)),
|
||||
createTexture(gl, size, size, makeNoise(0.8, 1.3)),
|
||||
createTexture(gl, size, size, makeNoise(1.7, 0.4)),
|
||||
];
|
||||
}
|
||||
|
||||
function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (channelUniformLocs[i] == null) continue;
|
||||
gl.activeTexture(gl.TEXTURE0 + i);
|
||||
gl.bindTexture(gl.TEXTURE_2D, channelTextures[i]);
|
||||
gl.uniform1i(channelUniformLocs[i], i);
|
||||
}
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
}
|
||||
|
||||
function sampleThumbStats(data, width, height) {
|
||||
const row = width * 4;
|
||||
let sumL = 0;
|
||||
let sumL2 = 0;
|
||||
let n = 0;
|
||||
for (let y = 0; y < height; y += 5) {
|
||||
for (let x = 0; x < width; x += 6) {
|
||||
const i = y * row + x * 4;
|
||||
if (i + 2 >= data.length) continue;
|
||||
const r = data[i] / 255;
|
||||
const g = data[i + 1] / 255;
|
||||
const b = data[i + 2] / 255;
|
||||
const L = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
sumL += L;
|
||||
sumL2 += L * L;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n < 4) return { mean: 0, varL: 0 };
|
||||
const mean = sumL / n;
|
||||
const varL = Math.max(0, sumL2 / n - mean * mean);
|
||||
return { mean, varL };
|
||||
}
|
||||
|
||||
function isAcceptableThumb(mean, varL) {
|
||||
if (mean < 0.017 || mean > 0.99) return false;
|
||||
if (varL < 0.00065) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function thumbScore(mean, varL) {
|
||||
return varL * 85 + (1 - Math.abs(mean - 0.4) * 1.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code — normalized GLSL (same as server returns)
|
||||
* @param {string} name — shader title for fallback hash
|
||||
* @returns {Promise<string>} data:image/png;base64,...
|
||||
*/
|
||||
window.captureShaderThumbnail = function captureShaderThumbnail(code, name) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = THUMB_W;
|
||||
canvas.height = THUMB_H;
|
||||
const gl = canvas.getContext("webgl2", GL_CONTEXT_OPTS);
|
||||
if (!gl) {
|
||||
return Promise.reject(new Error("当前浏览器不支持 WebGL2"));
|
||||
}
|
||||
let program;
|
||||
let codeForRender = code;
|
||||
try {
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e1) {
|
||||
try {
|
||||
codeForRender = toPortableGlsl(code);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
} catch (_e2) {
|
||||
codeForRender = fallbackShader(name);
|
||||
program = compileProgram(gl, buildFragmentShader(codeForRender));
|
||||
}
|
||||
}
|
||||
|
||||
const { vao } = createQuad(gl);
|
||||
const channelTextures = createDefaultChannelTextures(gl, 128);
|
||||
const channelUniforms = [
|
||||
gl.getUniformLocation(program, "iChannel0"),
|
||||
gl.getUniformLocation(program, "iChannel1"),
|
||||
gl.getUniformLocation(program, "iChannel2"),
|
||||
gl.getUniformLocation(program, "iChannel3"),
|
||||
];
|
||||
const uniforms = {
|
||||
iResolution: gl.getUniformLocation(program, "iResolution"),
|
||||
iTime: gl.getUniformLocation(program, "iTime"),
|
||||
iTimeDelta: gl.getUniformLocation(program, "iTimeDelta"),
|
||||
iFrame: gl.getUniformLocation(program, "iFrame"),
|
||||
iMouse: gl.getUniformLocation(program, "iMouse"),
|
||||
iDate: gl.getUniformLocation(program, "iDate"),
|
||||
};
|
||||
|
||||
gl.viewport(0, 0, THUMB_W, THUMB_H);
|
||||
gl.useProgram(program);
|
||||
bindChannelTextures(gl, channelUniforms, channelTextures);
|
||||
gl.uniform3f(uniforms.iResolution, THUMB_W, THUMB_H, 1);
|
||||
gl.uniform1f(uniforms.iTimeDelta, 0);
|
||||
setIDateUniform(gl, uniforms.iDate);
|
||||
gl.uniform4f(uniforms.iMouse, 0, 0, 0, 0);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
|
||||
|
||||
const pixels = new Uint8Array(THUMB_W * THUMB_H * 4);
|
||||
let bestStrict = { t: 0.5, score: -1e9 };
|
||||
let bestLoose = { t: 0.5, varL: -1 };
|
||||
|
||||
for (let ti = 0; ti < THUMB_TIME_CANDIDATES.length; ti++) {
|
||||
const t = THUMB_TIME_CANDIDATES[ti];
|
||||
gl.uniform1f(uniforms.iTime, t);
|
||||
gl.uniform1i(uniforms.iFrame, Math.max(0, Math.floor(t * 60)));
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.readPixels(0, 0, THUMB_W, THUMB_H, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const { mean, varL } = sampleThumbStats(pixels, THUMB_W, THUMB_H);
|
||||
if (varL > bestLoose.varL) bestLoose = { t, varL };
|
||||
if (isAcceptableThumb(mean, varL)) {
|
||||
const sc = thumbScore(mean, varL);
|
||||
if (sc > bestStrict.score) bestStrict = { t, score: sc };
|
||||
}
|
||||
}
|
||||
|
||||
const pickT = bestStrict.score > -1e8 ? bestStrict.t : bestLoose.t;
|
||||
gl.uniform1f(uniforms.iTime, pickT);
|
||||
gl.uniform1i(uniforms.iFrame, Math.max(0, Math.floor(pickT * 60)));
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const ext = gl.getExtension("WEBGL_lose_context");
|
||||
if (ext) ext.loseContext();
|
||||
return Promise.resolve(dataUrl);
|
||||
};
|
||||
})();
|
||||