diff --git a/admin.html b/admin.html index b8fbf04..3c94597 100644 --- a/admin.html +++ b/admin.html @@ -78,6 +78,7 @@
+ diff --git a/admin.js b/admin.js index e0774be..2f47de2 100644 --- a/admin.js +++ b/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 = ""; diff --git a/data/shaders.json b/data/shaders.json index f27126f..3a6d136 100644 --- a/data/shaders.json +++ b/data/shaders.json @@ -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" } ] \ No newline at end of file diff --git a/data/thumbnails/02bd9517-38ba-4392-8fa2-e79abc18eded.png b/data/thumbnails/02bd9517-38ba-4392-8fa2-e79abc18eded.png new file mode 100644 index 0000000..e429e7f Binary files /dev/null and b/data/thumbnails/02bd9517-38ba-4392-8fa2-e79abc18eded.png differ diff --git a/data/thumbnails/07545078-a585-4e2b-b35e-66f75f080bbd.png b/data/thumbnails/07545078-a585-4e2b-b35e-66f75f080bbd.png new file mode 100644 index 0000000..15c9bb4 Binary files /dev/null and b/data/thumbnails/07545078-a585-4e2b-b35e-66f75f080bbd.png differ diff --git a/data/thumbnails/0d1722d9-c2f6-4d69-8a97-31e5baba1975.png b/data/thumbnails/0d1722d9-c2f6-4d69-8a97-31e5baba1975.png new file mode 100644 index 0000000..04bd04d Binary files /dev/null and b/data/thumbnails/0d1722d9-c2f6-4d69-8a97-31e5baba1975.png differ diff --git a/data/thumbnails/256b1d36-4713-4103-b9d3-8f4e10667288.png b/data/thumbnails/256b1d36-4713-4103-b9d3-8f4e10667288.png new file mode 100644 index 0000000..0839b94 Binary files /dev/null and b/data/thumbnails/256b1d36-4713-4103-b9d3-8f4e10667288.png differ diff --git a/data/thumbnails/2881c592-2827-413b-8f91-270c2f6f6788.png b/data/thumbnails/2881c592-2827-413b-8f91-270c2f6f6788.png new file mode 100644 index 0000000..a1a1fe4 Binary files /dev/null and b/data/thumbnails/2881c592-2827-413b-8f91-270c2f6f6788.png differ diff --git a/data/thumbnails/29c5453f-5a8b-4995-a4a7-c8379e2d50e9.png b/data/thumbnails/29c5453f-5a8b-4995-a4a7-c8379e2d50e9.png new file mode 100644 index 0000000..2ae97d5 Binary files /dev/null and b/data/thumbnails/29c5453f-5a8b-4995-a4a7-c8379e2d50e9.png differ diff --git a/data/thumbnails/31f23d44-8403-4a22-bb0f-c8636bd7c447.png b/data/thumbnails/31f23d44-8403-4a22-bb0f-c8636bd7c447.png new file mode 100644 index 0000000..15c9bb4 Binary files /dev/null and b/data/thumbnails/31f23d44-8403-4a22-bb0f-c8636bd7c447.png differ diff --git a/data/thumbnails/3344f1cc-d547-4df9-9b96-866ab10a10e3.png b/data/thumbnails/3344f1cc-d547-4df9-9b96-866ab10a10e3.png new file mode 100644 index 0000000..a7a85a4 Binary files /dev/null and b/data/thumbnails/3344f1cc-d547-4df9-9b96-866ab10a10e3.png differ diff --git a/data/thumbnails/34548b08-2577-4fd9-b428-7deef9aca610.png b/data/thumbnails/34548b08-2577-4fd9-b428-7deef9aca610.png new file mode 100644 index 0000000..d46cf18 Binary files /dev/null and b/data/thumbnails/34548b08-2577-4fd9-b428-7deef9aca610.png differ diff --git a/data/thumbnails/347c3c57-1b24-49bb-9c1c-37b6a4aff0de.png b/data/thumbnails/347c3c57-1b24-49bb-9c1c-37b6a4aff0de.png new file mode 100644 index 0000000..4e847fd Binary files /dev/null and b/data/thumbnails/347c3c57-1b24-49bb-9c1c-37b6a4aff0de.png differ diff --git a/data/thumbnails/3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f.png b/data/thumbnails/3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f.png new file mode 100644 index 0000000..0f7c610 Binary files /dev/null and b/data/thumbnails/3cfa51f8-b1d3-48f2-9f52-9f6fca69b08f.png differ diff --git a/data/thumbnails/40cdf4d1-62fb-43fb-9891-3e7d4dc3c641.png b/data/thumbnails/40cdf4d1-62fb-43fb-9891-3e7d4dc3c641.png new file mode 100644 index 0000000..6a79bc9 Binary files /dev/null and b/data/thumbnails/40cdf4d1-62fb-43fb-9891-3e7d4dc3c641.png differ diff --git a/data/thumbnails/50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0.png b/data/thumbnails/50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0.png new file mode 100644 index 0000000..a247d40 Binary files /dev/null and b/data/thumbnails/50d1783d-35ec-4cc1-8d0a-0167bbf3c1e0.png differ diff --git a/data/thumbnails/62d7b82f-a078-4f08-af39-e527ba916a01.png b/data/thumbnails/62d7b82f-a078-4f08-af39-e527ba916a01.png new file mode 100644 index 0000000..97526b6 Binary files /dev/null and b/data/thumbnails/62d7b82f-a078-4f08-af39-e527ba916a01.png differ diff --git a/data/thumbnails/748ae90a-cc90-4388-ae20-5c18f43c8a4b.png b/data/thumbnails/748ae90a-cc90-4388-ae20-5c18f43c8a4b.png new file mode 100644 index 0000000..140bf42 Binary files /dev/null and b/data/thumbnails/748ae90a-cc90-4388-ae20-5c18f43c8a4b.png differ diff --git a/data/thumbnails/7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3.png b/data/thumbnails/7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3.png new file mode 100644 index 0000000..ce3bddc Binary files /dev/null and b/data/thumbnails/7c9bb8e9-503e-44ce-b5a0-85b9f11dbae3.png differ diff --git a/data/thumbnails/82ac512b-f051-42d1-b3cc-bd2f6d92fdb2.png b/data/thumbnails/82ac512b-f051-42d1-b3cc-bd2f6d92fdb2.png new file mode 100644 index 0000000..289ed4f Binary files /dev/null and b/data/thumbnails/82ac512b-f051-42d1-b3cc-bd2f6d92fdb2.png differ diff --git a/data/thumbnails/8d7c51dc-251c-4849-b053-499e55bbc586.png b/data/thumbnails/8d7c51dc-251c-4849-b053-499e55bbc586.png new file mode 100644 index 0000000..d95b95f Binary files /dev/null and b/data/thumbnails/8d7c51dc-251c-4849-b053-499e55bbc586.png differ diff --git a/data/thumbnails/a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff.png b/data/thumbnails/a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff.png new file mode 100644 index 0000000..8c179ec Binary files /dev/null and b/data/thumbnails/a5e23f6d-aa92-4ed1-9cea-4ba04181e3ff.png differ diff --git a/data/thumbnails/a8016745-d7ae-4fb3-ada9-0573e9fc213b.png b/data/thumbnails/a8016745-d7ae-4fb3-ada9-0573e9fc213b.png new file mode 100644 index 0000000..37d9eb0 Binary files /dev/null and b/data/thumbnails/a8016745-d7ae-4fb3-ada9-0573e9fc213b.png differ diff --git a/data/thumbnails/ab0cc421-e34d-4244-a540-03f029ec7372.png b/data/thumbnails/ab0cc421-e34d-4244-a540-03f029ec7372.png new file mode 100644 index 0000000..9fd3d63 Binary files /dev/null and b/data/thumbnails/ab0cc421-e34d-4244-a540-03f029ec7372.png differ diff --git a/data/thumbnails/ad4ccd5d-4c58-43f1-af72-fda7350bddc7.png b/data/thumbnails/ad4ccd5d-4c58-43f1-af72-fda7350bddc7.png new file mode 100644 index 0000000..7659b05 Binary files /dev/null and b/data/thumbnails/ad4ccd5d-4c58-43f1-af72-fda7350bddc7.png differ diff --git a/data/thumbnails/alien-core-ported.png b/data/thumbnails/alien-core-ported.png new file mode 100644 index 0000000..0df269c Binary files /dev/null and b/data/thumbnails/alien-core-ported.png differ diff --git a/data/thumbnails/b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354.png b/data/thumbnails/b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354.png new file mode 100644 index 0000000..d119a96 Binary files /dev/null and b/data/thumbnails/b08237ae-6fc0-4b06-bfb8-ae0f6a8e1354.png differ diff --git a/data/thumbnails/bb28628f-5dbc-458a-9681-35904eb94590.png b/data/thumbnails/bb28628f-5dbc-458a-9681-35904eb94590.png new file mode 100644 index 0000000..774620d Binary files /dev/null and b/data/thumbnails/bb28628f-5dbc-458a-9681-35904eb94590.png differ diff --git a/data/thumbnails/cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37.png b/data/thumbnails/cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37.png new file mode 100644 index 0000000..ce9c5a5 Binary files /dev/null and b/data/thumbnails/cddda4e7-5f78-4c1b-8aaf-5d7e7e0f0a37.png differ diff --git a/data/thumbnails/d3cc9a0f-0a9b-4094-a4a4-af65b6666b18.png b/data/thumbnails/d3cc9a0f-0a9b-4094-a4a4-af65b6666b18.png new file mode 100644 index 0000000..914fa0b Binary files /dev/null and b/data/thumbnails/d3cc9a0f-0a9b-4094-a4a4-af65b6666b18.png differ diff --git a/data/thumbnails/d5728c14-a698-4a58-94d7-76a81f97cc99.png b/data/thumbnails/d5728c14-a698-4a58-94d7-76a81f97cc99.png new file mode 100644 index 0000000..fd0fb53 Binary files /dev/null and b/data/thumbnails/d5728c14-a698-4a58-94d7-76a81f97cc99.png differ diff --git a/data/thumbnails/d6942ee2-97be-4d9d-bfaf-c0fde0558de0.png b/data/thumbnails/d6942ee2-97be-4d9d-bfaf-c0fde0558de0.png new file mode 100644 index 0000000..7cb106b Binary files /dev/null and b/data/thumbnails/d6942ee2-97be-4d9d-bfaf-c0fde0558de0.png differ diff --git a/data/thumbnails/d90c5b45-858f-41ca-a826-28ab3444188b.png b/data/thumbnails/d90c5b45-858f-41ca-a826-28ab3444188b.png new file mode 100644 index 0000000..12200bd Binary files /dev/null and b/data/thumbnails/d90c5b45-858f-41ca-a826-28ab3444188b.png differ diff --git a/data/thumbnails/da6767b8-bca1-4c1f-a18d-70767db6d81c.png b/data/thumbnails/da6767b8-bca1-4c1f-a18d-70767db6d81c.png new file mode 100644 index 0000000..1886e3a Binary files /dev/null and b/data/thumbnails/da6767b8-bca1-4c1f-a18d-70767db6d81c.png differ diff --git a/data/thumbnails/dd9bfa65-1443-45b5-a83b-d25538b2ddbd.png b/data/thumbnails/dd9bfa65-1443-45b5-a83b-d25538b2ddbd.png new file mode 100644 index 0000000..919d07d Binary files /dev/null and b/data/thumbnails/dd9bfa65-1443-45b5-a83b-d25538b2ddbd.png differ diff --git a/data/thumbnails/e1f817dd-627c-419c-a5ce-7dc7a4b05f64.png b/data/thumbnails/e1f817dd-627c-419c-a5ce-7dc7a4b05f64.png new file mode 100644 index 0000000..f071324 Binary files /dev/null and b/data/thumbnails/e1f817dd-627c-419c-a5ce-7dc7a4b05f64.png differ diff --git a/data/thumbnails/e25336ee-9857-47b8-b5da-a87267dc6aad.png b/data/thumbnails/e25336ee-9857-47b8-b5da-a87267dc6aad.png new file mode 100644 index 0000000..b504394 Binary files /dev/null and b/data/thumbnails/e25336ee-9857-47b8-b5da-a87267dc6aad.png differ diff --git a/data/thumbnails/f12eec79-b870-45c7-a74f-35c2a69d84c2.png b/data/thumbnails/f12eec79-b870-45c7-a74f-35c2a69d84c2.png new file mode 100644 index 0000000..60852aa Binary files /dev/null and b/data/thumbnails/f12eec79-b870-45c7-a74f-35c2a69d84c2.png differ diff --git a/data/thumbnails/f77eacef-c66d-4a84-b0f5-2044b6a83b5b.png b/data/thumbnails/f77eacef-c66d-4a84-b0f5-2044b6a83b5b.png new file mode 100644 index 0000000..9ab8ffc Binary files /dev/null and b/data/thumbnails/f77eacef-c66d-4a84-b0f5-2044b6a83b5b.png differ diff --git a/data/thumbnails/ff181924-a244-493f-acc4-da5df3c3fbd4.png b/data/thumbnails/ff181924-a244-493f-acc4-da5df3c3fbd4.png new file mode 100644 index 0000000..fb9017c Binary files /dev/null and b/data/thumbnails/ff181924-a244-493f-acc4-da5df3c3fbd4.png differ diff --git a/display.js b/display.js index f71b6fe..68ad638 100644 --- a/display.js +++ b/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 = ``; +} + +/** 全屏详情:限制像素量以便稳定 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 + ? `` + : `
暂无缩略图
正在尝试自动生成…
`; + + card.dataset.shaderId = id; card.innerHTML = `
${name} -
- -
- +
${visualInner}
by ${author} views ${views} | likes ${likes}
-
`; - 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 || ""; diff --git a/index.html b/index.html index 836f144..0b8a61d 100644 --- a/index.html +++ b/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 @@
准备就绪
-

- 列表默认仅显示首帧静态预览;鼠标悬停在卡片上时才会持续渲染动画。点击进入全屏仍为实时播放。 -

+
@@ -324,6 +353,7 @@ + diff --git a/server.js b/server.js index e6ab6fc..3be2e0d 100644 --- a/server.js +++ b/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: "删除失败" }); diff --git a/thumb-renderer.js b/thumb-renderer.js new file mode 100644 index 0000000..de80ac9 --- /dev/null +++ b/thumb-renderer.js @@ -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} 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); + }; +})();