feat:优化数据

This commit is contained in:
Daniel
2026-04-02 10:53:36 +08:00
parent a5bf2adad9
commit 0304805ce1
44 changed files with 902 additions and 392 deletions

View File

@@ -78,6 +78,7 @@
</div> </div>
<div class="card list" id="list"></div> <div class="card list" id="list"></div>
</div> </div>
<script src="./thumb-renderer.js"></script>
<script src="./admin.js"></script> <script src="./admin.js"></script>
</body> </body>
</html> </html>

View File

@@ -83,6 +83,22 @@ saveBtn.addEventListener("click", async () => {
if (payload.sourceFormat === "angle-metal-auto-converted") { if (payload.sourceFormat === "angle-metal-auto-converted") {
alert("已自动解构并转换为 GLSL展示页可直接渲染。"); 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 = ""; nameInput.value = "";
authorInput.value = ""; authorInput.value = "";
codeInput.value = ""; codeInput.value = "";

View File

@@ -7,7 +7,8 @@
"views": 11032, "views": 11032,
"likes": 192, "likes": 192,
"createdAt": "2026-04-02T01:57:49.494Z", "createdAt": "2026-04-02T01:57:49.494Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.312Z"
}, },
{ {
"id": "b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354", "id": "b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354",
@@ -17,7 +18,8 @@
"views": 3849, "views": 3849,
"likes": 532, "likes": 532,
"createdAt": "2026-04-02T01:56:37.273Z", "createdAt": "2026-04-02T01:56:37.273Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.455Z"
}, },
{ {
"id": "50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0", "id": "50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0",
@@ -27,7 +29,8 @@
"views": 5343, "views": 5343,
"likes": 361, "likes": 361,
"createdAt": "2026-04-02T01:53:17.707Z", "createdAt": "2026-04-02T01:53:17.707Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.597Z"
}, },
{ {
"id": "dd9bfa65-1443-45b5-a83b-d25538b2ddbd", "id": "dd9bfa65-1443-45b5-a83b-d25538b2ddbd",
@@ -37,7 +40,8 @@
"views": 6478, "views": 6478,
"likes": 386, "likes": 386,
"createdAt": "2026-04-02T01:48:43.430Z", "createdAt": "2026-04-02T01:48:43.430Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.728Z"
}, },
{ {
"id": "d3cc9a0f-0a9b-4094-a4a4-af65b6666b18", "id": "d3cc9a0f-0a9b-4094-a4a4-af65b6666b18",
@@ -47,7 +51,8 @@
"views": 7627, "views": 7627,
"likes": 65, "likes": 65,
"createdAt": "2026-04-02T01:44:54.863Z", "createdAt": "2026-04-02T01:44:54.863Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.841Z"
}, },
{ {
"id": "ff181924-a244-493f-acc4-da5df3c3fbd4", "id": "ff181924-a244-493f-acc4-da5df3c3fbd4",
@@ -57,7 +62,8 @@
"views": 14244, "views": 14244,
"likes": 240, "likes": 240,
"createdAt": "2026-04-02T01:43:43.091Z", "createdAt": "2026-04-02T01:43:43.091Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:18.959Z"
}, },
{ {
"id": "02bd9517-38ba-4392-8fa2-e79abc18eded", "id": "02bd9517-38ba-4392-8fa2-e79abc18eded",
@@ -67,7 +73,8 @@
"views": 19672, "views": 19672,
"likes": 529, "likes": 529,
"createdAt": "2026-04-02T01:40:46.648Z", "createdAt": "2026-04-02T01:40:46.648Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.076Z"
}, },
{ {
"id": "29c5453f-5a8b-4995-a4a7-c8379e2d50e9", "id": "29c5453f-5a8b-4995-a4a7-c8379e2d50e9",
@@ -77,7 +84,8 @@
"views": 16421, "views": 16421,
"likes": 420, "likes": 420,
"createdAt": "2026-04-02T01:39:56.715Z", "createdAt": "2026-04-02T01:39:56.715Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.186Z"
}, },
{ {
"id": "a8016745-d7ae-4fb3-ada9-0573e9fc213b", "id": "a8016745-d7ae-4fb3-ada9-0573e9fc213b",
@@ -87,7 +95,8 @@
"views": 22975, "views": 22975,
"likes": 583, "likes": 583,
"createdAt": "2026-04-02T01:37:37.687Z", "createdAt": "2026-04-02T01:37:37.687Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.282Z"
}, },
{ {
"id": "0d1722d9-c2f6-4d69-8a97-31e5baba1975", "id": "0d1722d9-c2f6-4d69-8a97-31e5baba1975",
@@ -97,7 +106,8 @@
"views": 24037, "views": 24037,
"likes": 83, "likes": 83,
"createdAt": "2026-04-02T01:36:38.486Z", "createdAt": "2026-04-02T01:36:38.486Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.388Z"
}, },
{ {
"id": "8d7c51dc-251c-4849-b053-499e55bbc586", "id": "8d7c51dc-251c-4849-b053-499e55bbc586",
@@ -107,7 +117,8 @@
"views": 14891, "views": 14891,
"likes": 667, "likes": 667,
"createdAt": "2026-04-02T01:35:16.255Z", "createdAt": "2026-04-02T01:35:16.255Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.507Z"
}, },
{ {
"id": "f12eec79-b870-45c7-a74f-35c2a69d84c2", "id": "f12eec79-b870-45c7-a74f-35c2a69d84c2",
@@ -117,7 +128,8 @@
"views": 16201, "views": 16201,
"likes": 396, "likes": 396,
"createdAt": "2026-04-01T16:06:40.801Z", "createdAt": "2026-04-01T16:06:40.801Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.627Z"
}, },
{ {
"id": "3344f1cc-d547-4df9-9b96-866ab10a10e3", "id": "3344f1cc-d547-4df9-9b96-866ab10a10e3",
@@ -127,7 +139,8 @@
"views": 6862, "views": 6862,
"likes": 334, "likes": 334,
"createdAt": "2026-04-01T16:03:52.234Z", "createdAt": "2026-04-01T16:03:52.234Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.764Z"
}, },
{ {
"id": "bb28628f-5dbc-458a-9681-35904eb94590", "id": "bb28628f-5dbc-458a-9681-35904eb94590",
@@ -137,7 +150,8 @@
"views": 23256, "views": 23256,
"likes": 84, "likes": 84,
"createdAt": "2026-04-01T16:01:12.555Z", "createdAt": "2026-04-01T16:01:12.555Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.876Z"
}, },
{ {
"id": "40cdf4d1-62fb-43fb-9891-3e7d4dc3c641", "id": "40cdf4d1-62fb-43fb-9891-3e7d4dc3c641",
@@ -147,7 +161,8 @@
"views": 12044, "views": 12044,
"likes": 244, "likes": 244,
"createdAt": "2026-04-01T16:00:02.916Z", "createdAt": "2026-04-01T16:00:02.916Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:19.973Z"
}, },
{ {
"id": "82ac512b-f051-42d1-b3cc-bd2f6d92fdb2", "id": "82ac512b-f051-42d1-b3cc-bd2f6d92fdb2",
@@ -157,7 +172,8 @@
"views": 19147, "views": 19147,
"likes": 521, "likes": 521,
"createdAt": "2026-04-01T15:58:19.514Z", "createdAt": "2026-04-01T15:58:19.514Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.102Z"
}, },
{ {
"id": "e25336ee-9857-47b8-b5da-a87267dc6aad", "id": "e25336ee-9857-47b8-b5da-a87267dc6aad",
@@ -167,7 +183,8 @@
"views": 10493, "views": 10493,
"likes": 103, "likes": 103,
"createdAt": "2026-04-01T15:57:36.080Z", "createdAt": "2026-04-01T15:57:36.080Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.246Z"
}, },
{ {
"id": "d6942ee2-97be-4d9d-bfaf-c0fde0558de0", "id": "d6942ee2-97be-4d9d-bfaf-c0fde0558de0",
@@ -177,7 +194,8 @@
"views": 19836, "views": 19836,
"likes": 476, "likes": 476,
"createdAt": "2026-04-01T15:57:05.112Z", "createdAt": "2026-04-01T15:57:05.112Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.351Z"
}, },
{ {
"id": "34548b08-2577-4fd9-b428-7deef9aca610", "id": "34548b08-2577-4fd9-b428-7deef9aca610",
@@ -187,7 +205,8 @@
"views": 19599, "views": 19599,
"likes": 721, "likes": 721,
"createdAt": "2026-04-01T15:56:37.442Z", "createdAt": "2026-04-01T15:56:37.442Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.470Z"
}, },
{ {
"id": "256b1d36-4713-4103-b9d3-8f4e10667288", "id": "256b1d36-4713-4103-b9d3-8f4e10667288",
@@ -197,7 +216,8 @@
"views": 22519, "views": 22519,
"likes": 636, "likes": 636,
"createdAt": "2026-04-01T15:56:04.431Z", "createdAt": "2026-04-01T15:56:04.431Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.590Z"
}, },
{ {
"id": "f77eacef-c66d-4a84-b0f5-2044b6a83b5b", "id": "f77eacef-c66d-4a84-b0f5-2044b6a83b5b",
@@ -207,7 +227,8 @@
"views": 7870, "views": 7870,
"likes": 161, "likes": 161,
"createdAt": "2026-04-01T15:55:32.788Z", "createdAt": "2026-04-01T15:55:32.788Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.742Z"
}, },
{ {
"id": "62d7b82f-a078-4f08-af39-e527ba916a01", "id": "62d7b82f-a078-4f08-af39-e527ba916a01",
@@ -217,7 +238,8 @@
"views": 13977, "views": 13977,
"likes": 658, "likes": 658,
"createdAt": "2026-04-01T15:54:30.908Z", "createdAt": "2026-04-01T15:54:30.908Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.878Z"
}, },
{ {
"id": "748ae90a-cc90-4388-ae20-5c18f43c8a4b", "id": "748ae90a-cc90-4388-ae20-5c18f43c8a4b",
@@ -227,7 +249,8 @@
"views": 18580, "views": 18580,
"likes": 63, "likes": 63,
"createdAt": "2026-04-01T15:54:03.238Z", "createdAt": "2026-04-01T15:54:03.238Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:20.995Z"
}, },
{ {
"id": "31f23d44-8403-4a22-bb0f-c8636bd7c447", "id": "31f23d44-8403-4a22-bb0f-c8636bd7c447",
@@ -237,7 +260,8 @@
"views": 13711, "views": 13711,
"likes": 399, "likes": 399,
"createdAt": "2026-04-01T15:53:34.082Z", "createdAt": "2026-04-01T15:53:34.082Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.103Z"
}, },
{ {
"id": "da6767b8-bca1-4c1f-a18d-70767db6d81c", "id": "da6767b8-bca1-4c1f-a18d-70767db6d81c",
@@ -247,7 +271,8 @@
"views": 21588, "views": 21588,
"likes": 137, "likes": 137,
"createdAt": "2026-04-01T15:52:47.322Z", "createdAt": "2026-04-01T15:52:47.322Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.284Z"
}, },
{ {
"id": "3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f", "id": "3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f",
@@ -257,7 +282,8 @@
"views": 21349, "views": 21349,
"likes": 484, "likes": 484,
"createdAt": "2026-04-01T15:51:59.158Z", "createdAt": "2026-04-01T15:51:59.158Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.480Z"
}, },
{ {
"id": "ad4ccd5d-4c58-43f1-af72-fda7350bddc7", "id": "ad4ccd5d-4c58-43f1-af72-fda7350bddc7",
@@ -267,7 +293,8 @@
"views": 21822, "views": 21822,
"likes": 311, "likes": 311,
"createdAt": "2026-04-01T15:35:13.690Z", "createdAt": "2026-04-01T15:35:13.690Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.592Z"
}, },
{ {
"id": "7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3", "id": "7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3",
@@ -277,7 +304,8 @@
"views": 3108, "views": 3108,
"likes": 51, "likes": 51,
"createdAt": "2026-04-01T15:34:35.030Z", "createdAt": "2026-04-01T15:34:35.030Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.704Z"
}, },
{ {
"id": "2881c592-2827-413b-8f91-270c2f6f6788", "id": "2881c592-2827-413b-8f91-270c2f6f6788",
@@ -287,7 +315,8 @@
"views": 24459, "views": 24459,
"likes": 695, "likes": 695,
"createdAt": "2026-04-01T15:33:45.538Z", "createdAt": "2026-04-01T15:33:45.538Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.829Z"
}, },
{ {
"id": "cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37", "id": "cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37",
@@ -297,7 +326,8 @@
"views": 12483, "views": 12483,
"likes": 705, "likes": 705,
"createdAt": "2026-04-01T15:31:53.940Z", "createdAt": "2026-04-01T15:31:53.940Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:21.991Z"
}, },
{ {
"id": "e1f817dd-627c-419c-a5ce-7dc7a4b05f64", "id": "e1f817dd-627c-419c-a5ce-7dc7a4b05f64",
@@ -307,7 +337,8 @@
"views": 5699, "views": 5699,
"likes": 105, "likes": 105,
"createdAt": "2026-04-01T15:29:41.289Z", "createdAt": "2026-04-01T15:29:41.289Z",
"sourceFormat": "glsl" "sourceFormat": "glsl",
"thumbnailAt": "2026-04-02T02:38:22.117Z"
}, },
{ {
"id": "07545078-a585-4e2b-b35e-66f75f080bbd", "id": "07545078-a585-4e2b-b35e-66f75f080bbd",
@@ -318,7 +349,8 @@
"likes": 195, "likes": 195,
"createdAt": "2026-04-01T15:25:55.867Z", "createdAt": "2026-04-01T15:25:55.867Z",
"sourceFormat": "glsl", "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", "id": "d90c5b45-858f-41ca-a826-28ab3444188b",
@@ -329,7 +361,8 @@
"likes": 545, "likes": 545,
"createdAt": "2026-04-01T15:24:44.569Z", "createdAt": "2026-04-01T15:24:44.569Z",
"sourceFormat": "glsl", "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", "id": "a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff",
@@ -340,7 +373,8 @@
"likes": 76, "likes": 76,
"createdAt": "2026-04-01T15:24:08.297Z", "createdAt": "2026-04-01T15:24:08.297Z",
"sourceFormat": "glsl", "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", "id": "ab0cc421-e34d-4244-a540-03f029ec7372",
@@ -351,7 +385,8 @@
"likes": 487, "likes": 487,
"createdAt": "2026-04-01T15:17:20.407Z", "createdAt": "2026-04-01T15:17:20.407Z",
"sourceFormat": "glsl", "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", "id": "d5728c14-a698-4a58-94d7-76a81f97cc99",
@@ -362,7 +397,8 @@
"likes": 171, "likes": 171,
"createdAt": "2026-04-01T15:06:25.335Z", "createdAt": "2026-04-01T15:06:25.335Z",
"sourceFormat": "glsl", "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", "id": "alien-core-ported",
@@ -371,6 +407,7 @@
"views": 9754, "views": 9754,
"likes": 256, "likes": 256,
"createdAt": "2026-04-01T00:00:00.000Z", "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"
} }
] ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -13,11 +13,85 @@ const backBtn = document.getElementById("back-btn");
const API_BASE = "/api/shaders"; const API_BASE = "/api/shaders";
/** Grid previews: limit fill-rate so many cards can stay near 60fps (detail view uses full quality). */ /** 串行补全缺失缩略图(每步独立 WebGL用完即释放避免多卡片同时建上下文 */
const PREVIEW_MAX_PIXEL_RATIO = 1.25; const thumbBackfillQueue = [];
const PREVIEW_MAX_BUFFER_LONG_SIDE = 520; const thumbBackfillQueuedIds = new Set();
const DETAIL_MAX_PIXEL_RATIO = 2; const thumbBackfillFailUntil = new Map();
const DETAIL_MAX_BUFFER_LONG_SIDE = 2560; 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 = { const GL_CONTEXT_OPTS = {
alpha: false, alpha: false,
@@ -28,12 +102,6 @@ const GL_CONTEXT_OPTS = {
desynchronized: true, 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 const vertexShaderSource = `#version 300 es
precision highp float; precision highp float;
layout(location = 0) in vec2 aPosition; layout(location = 0) in vec2 aPosition;
@@ -254,19 +322,6 @@ function bindChannelTextures(gl, channelUniformLocs, channelTextures) {
gl.activeTexture(gl.TEXTURE0); 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) { function computeDetailBufferSize(cssW, cssH) {
const dpr = Math.min(window.devicePixelRatio || 1, DETAIL_MAX_PIXEL_RATIO); const dpr = Math.min(window.devicePixelRatio || 1, DETAIL_MAX_PIXEL_RATIO);
let w = Math.max(1, Math.floor(cssW * dpr)); let w = Math.max(1, Math.floor(cssW * dpr));
@@ -280,120 +335,21 @@ function computeDetailBufferSize(cssW, cssH) {
return { w, h }; return { w, h };
} }
function countGridWebGLContexts() { function computeHoverBufferSize(cssW, cssH) {
let n = 0; const dpr = Math.min(window.devicePixelRatio || 1, HOVER_MAX_PIXEL_RATIO);
for (const p of previews) { let w = Math.max(1, Math.floor(cssW * dpr));
if (p.gl) n += 1; 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; return { w, h };
}
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;
} }
const previews = []; 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; let renderRafId = 0;
function scheduleRender() { 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() { function shouldScheduleNextFrame() {
if (detailRuntime) return true; if (detailRuntime) return true;
if (shouldKeepAnimatingGrid()) return true; if (gridHoverRuntime) return true;
if (needsStaticThumbnailPass()) return true; return !paused;
return false;
} }
let paused = false; let paused = false;
@@ -447,150 +380,188 @@ let detailRuntime = null;
let searchKeyword = ""; let searchKeyword = "";
let lastSignature = ""; 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) { 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"); const card = document.createElement("article");
card.className = "card"; card.className = "card";
card.dataset.search = `${name} ${author}`.toLowerCase(); 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 = ` card.innerHTML = `
<div class="card-head"> <div class="card-head">
<strong>${name}</strong> <strong>${name}</strong>
<div style="display:flex; gap:6px;">
<button type="button" class="pause-local-btn">暂停</button>
</div> </div>
</div> <div class="card-stage">${visualInner}</div>
<canvas></canvas>
<div class="card-meta"> <div class="card-meta">
<span>by ${author}</span> <span>by ${author}</span>
<span>views ${views} | likes ${likes}</span> <span>views ${views} | likes ${likes}</span>
</div> </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 = { const state = {
id, id,
name, name,
code, code,
codeForRender: code,
compileFailed: false,
card, 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", () => { card.addEventListener("mouseenter", () => {
state.isPointerOver = true; if (detailViewEl.classList.contains("active")) return;
state.lastTick = performance.now(); startGridHover(state, card);
scheduleRender();
}); });
card.addEventListener("mouseleave", () => { card.addEventListener("mouseleave", () => {
state.isPointerOver = false; if (gridHoverRuntime && gridHoverRuntime.state.id === state.id) tearDownGridHover();
state.localTime = 0;
state.localFrame = 0;
state._staticDirty = true;
state._staticRendered = false;
scheduleRender();
}); });
card.addEventListener("mousemove", (event) => {
canvas.addEventListener("mousemove", (event) => { if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
const rect = canvas.getBoundingClientRect(); const c = gridHoverRuntime.canvas;
const bw = canvas.width || Math.max(1, state._layoutW || rect.width); const rect = c.getBoundingClientRect();
const bh = canvas.height || Math.max(1, state._layoutH || rect.height); const bw = c.width || 1;
state.mouse.x = ((event.clientX - rect.left) / rect.width) * bw; const bh = c.height || 1;
state.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh; gridHoverRuntime.mouse.x = ((event.clientX - rect.left) / rect.width) * bw;
gridHoverRuntime.mouse.y = ((rect.bottom - event.clientY) / rect.height) * bh;
}); });
canvas.addEventListener("mousedown", () => { card.addEventListener("mousedown", () => {
state.mouse.down = true; if (!gridHoverRuntime || gridHoverRuntime.state.id !== state.id) return;
state.mouse.downX = state.mouse.x; gridHoverRuntime.mouse.down = true;
state.mouse.downY = state.mouse.y; 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); 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); previews.push(state);
} }
function clearPreviews() { function clearPreviews() {
tearDownGridHover();
previews.forEach((preview) => { previews.forEach((preview) => {
tearDownPreviewWebGL(preview);
if (preview._resizeObserver) preview._resizeObserver.disconnect();
if (preview._io) preview._io.disconnect();
preview.card.remove(); preview.card.remove();
}); });
previews.length = 0; previews.length = 0;
@@ -610,19 +581,30 @@ function closeDetail() {
} }
function openDetail(previewState) { function openDetail(previewState) {
tearDownGridHover();
closeDetail(); closeDetail();
const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS); const gl = detailCanvas.getContext("webgl2", GL_CONTEXT_OPTS);
if (!gl) return; if (!gl) return;
let program; let program;
const detailCode = previewState.codeForRender || previewState.code; let detailCode = previewState.code;
try {
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (_e1) {
try {
detailCode = toPortableGlsl(previewState.code);
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (_e2) {
detailCode = fallbackShader(previewState.name);
try { try {
program = compileProgram(gl, buildFragmentShader(detailCode)); program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (err) { } catch (err) {
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`; statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
return; return;
} }
}
}
const { vao, vbo } = createQuad(gl); const { vao, vbo } = createQuad(gl);
const channelTextures = createDefaultChannelTextures(gl, 256); const channelTextures = createDefaultChannelTextures(gl, 128);
const channelUniforms = [ const channelUniforms = [
gl.getUniformLocation(program, "iChannel0"), gl.getUniformLocation(program, "iChannel0"),
gl.getUniformLocation(program, "iChannel1"), gl.getUniformLocation(program, "iChannel1"),
@@ -679,7 +661,12 @@ async function loadShaders() {
if (!res.ok) throw new Error("加载失败"); if (!res.ok) throw new Error("加载失败");
const shaders = await res.json(); const shaders = await res.json();
const signature = JSON.stringify( 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) { if (signature !== lastSignature) {
lastSignature = signature; lastSignature = signature;
@@ -688,6 +675,7 @@ async function loadShaders() {
applySearch(); applySearch();
syncStats(); syncStats();
} }
scheduleThumbBackfill(shaders);
} catch (err) { } catch (err) {
statsEl.textContent = `读取后端失败:${String(err.message || err)}`; statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
} }
@@ -716,63 +704,35 @@ function renderAll(ts) {
fpsCounter += 1; fpsCounter += 1;
} }
const detailOpen = !!detailRuntime; if (gridHoverRuntime) {
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms, canvas } =
for (const preview of previews) { gridHoverRuntime;
let localDt = 0; if (gl.isContextLost()) {
if (!detailOpen) { tearDownGridHover();
} else {
const now = performance.now(); const now = performance.now();
localDt = Math.min((now - preview.lastTick) / 1000, 0.05); const localDt = Math.min((now - gridHoverRuntime.lastTick) / 1000, 0.05);
preview.lastTick = now; gridHoverRuntime.lastTick = now;
if (preview.isPointerOver && preview.isPlaying && !paused) { if (!paused) {
preview.localTime += localDt; gridHoverRuntime.localTime += localDt;
preview.localFrame += 1; gridHoverRuntime.localFrame += 1;
} }
} const lw =
gridHoverRuntime._layoutW > 0 ? gridHoverRuntime._layoutW : canvas.clientWidth || 1;
if ( const lh =
!detailOpen && gridHoverRuntime._layoutH > 0 ? gridHoverRuntime._layoutH : canvas.clientHeight || 1;
preview.card.style.display !== "none" && const { w, h } = computeHoverBufferSize(lw, lh);
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) { if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.width = w;
canvas.height = h; canvas.height = h;
gl.viewport(0, 0, w, h); gl.viewport(0, 0, w, h);
if (!isAnim) preview._staticDirty = true;
} }
gl.useProgram(program); gl.useProgram(program);
bindChannelTextures(gl, channelUniforms, channelTextures); bindChannelTextures(gl, channelUniforms, channelTextures);
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1); gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
if (isAnim) { gl.uniform1f(uniforms.iTime, gridHoverRuntime.localTime);
gl.uniform1f(uniforms.iTime, preview.localTime); gl.uniform1f(uniforms.iTimeDelta, paused ? 0 : localDt);
gl.uniform1f(uniforms.iTimeDelta, preview.isPlaying && !paused ? localDt : 0); gl.uniform1i(uniforms.iFrame, gridHoverRuntime.localFrame);
gl.uniform1i(uniforms.iFrame, preview.localFrame);
} else {
gl.uniform1f(uniforms.iTime, 0);
gl.uniform1f(uniforms.iTimeDelta, 0);
gl.uniform1i(uniforms.iFrame, 0);
}
setIDateUniform(gl, uniforms.iDate); setIDateUniform(gl, uniforms.iDate);
gl.uniform4f( gl.uniform4f(
uniforms.iMouse, uniforms.iMouse,
@@ -784,15 +744,16 @@ function renderAll(ts) {
gl.bindVertexArray(vao); gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6); gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null); gl.bindVertexArray(null);
preview._glTouchSeq = ++gridGlTouchCounter;
if (!isAnim) {
preview._staticRendered = true;
preview._staticDirty = false;
} }
} }
if (detailRuntime) { if (detailRuntime) {
const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime; const { gl, program, vao, uniforms, mouse, channelTextures, channelUniforms } = detailRuntime;
if (gl.isContextLost()) {
closeDetail();
scheduleRender();
return;
}
const lw = const lw =
detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1; detailRuntime._layoutW > 0 ? detailRuntime._layoutW : detailCanvas.clientWidth || 1;
const lh = const lh =
@@ -866,8 +827,8 @@ detailCanvas.addEventListener("mousedown", () => {
detailRuntime.mouse.downY = detailRuntime.mouse.y; detailRuntime.mouse.downY = detailRuntime.mouse.y;
}); });
window.addEventListener("mouseup", () => { window.addEventListener("mouseup", () => {
if (!detailRuntime) return; if (detailRuntime) detailRuntime.mouse.down = false;
detailRuntime.mouse.down = false; if (gridHoverRuntime) gridHoverRuntime.mouse.down = false;
}); });
searchInput.addEventListener("input", (event) => { searchInput.addEventListener("input", (event) => {
searchKeyword = event.target.value || ""; searchKeyword = event.target.value || "";

View File

@@ -160,11 +160,42 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
} }
.card canvas { .card-stage {
position: relative;
width: 100%; width: 100%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
display: block;
background: #000; 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 { .card-meta {
@@ -304,9 +335,7 @@
</div> </div>
</div> </div>
<div class="stats" id="stats">准备就绪</div> <div class="stats" id="stats">准备就绪</div>
<p class="view-hint">
列表默认仅显示首帧静态预览;鼠标悬停在卡片上时才会持续渲染动画。点击进入全屏仍为实时播放。
</p>
<section id="preview-grid" class="preview-grid"></section> <section id="preview-grid" class="preview-grid"></section>
</main> </main>
</div> </div>
@@ -324,6 +353,7 @@
</div> </div>
</section> </section>
<script src="./thumb-renderer.js"></script>
<script src="./display.js"></script> <script src="./display.js"></script>
</body> </body>
</html> </html>

107
server.js
View File

@@ -6,8 +6,9 @@ const crypto = require("crypto");
const app = express(); const app = express();
const PORT = process.env.PORT || 5180; const PORT = process.env.PORT || 5180;
const DB_PATH = path.join(__dirname, "data", "shaders.json"); 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)); app.use(express.static(__dirname));
async function ensureDb() { 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() { async function readDb() {
await ensureDb(); await ensureDb();
const raw = await fs.readFile(DB_PATH, "utf8"); const raw = await fs.readFile(DB_PATH, "utf8");
@@ -286,6 +331,7 @@ async function autoNormalizeStoredShaders() {
app.get("/api/shaders", async (_req, res) => { app.get("/api/shaders", async (_req, res) => {
try { try {
await ensureThumbnailsDir();
let shaders = await readDb(); let shaders = await readDb();
// Runtime safeguard: always return normalized code for display layer. // Runtime safeguard: always return normalized code for display layer.
shaders = shaders.map((item) => { shaders = shaders.map((item) => {
@@ -297,7 +343,16 @@ app.get("/api/shaders", async (_req, res) => {
if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted"; if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted";
return next; 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 { } catch {
res.status(500).json({ error: "读取失败" }); 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) => { app.delete("/api/shaders/:id", async (req, res) => {
try { try {
const shaders = await readDb(); const shaders = await readDb();
@@ -341,6 +438,12 @@ app.delete("/api/shaders/:id", async (req, res) => {
return res.status(404).json({ error: "未找到该 shader" }); return res.status(404).json({ error: "未找到该 shader" });
} }
await writeDb(next); await writeDb(next);
await ensureThumbnailsDir();
try {
await fs.unlink(thumbPath(req.params.id));
} catch (_) {
/* no thumb */
}
res.status(204).end(); res.status(204).end();
} catch { } catch {
res.status(500).json({ error: "删除失败" }); res.status(500).json({ error: "删除失败" });

362
thumb-renderer.js Normal file
View 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);
};
})();