From 8d69c0979c3d5511b8d22955f3d3a62ab1617247 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 2 Apr 2026 11:15:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + admin.html | 422 ++++++++++++++++++++--- admin.js | 26 +- display.js | 128 ++++++- docker-compose.yml | 2 +- index.html | 579 +++++++++++++++++++++++++------- node_modules/.package-lock.json | 6 + package-lock.json | 9 +- package.json | 3 +- server.js | 85 ++++- start-docker-cn.sh | 67 +++- stats-db.js | 155 +++++++++ thumb-renderer.js | 10 - 13 files changed, 1283 insertions(+), 214 deletions(-) create mode 100644 .gitignore create mode 100644 stats-db.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29e292e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +npm-debug.log* +*.local +data/stats.sqlite diff --git a/admin.html b/admin.html index 3c94597..8956803 100644 --- a/admin.html +++ b/admin.html @@ -3,80 +3,414 @@ - Shader 管理后台 + 管理后台 — Shadervault + + +
-
-

Shader 管理后台

- 返回展示页 -
-
+
+
+

Shader 管理

+

保存后同步展示页并生成缩略图。

+
+ + + 返回展示页 + +
+ +
+

新建 / 编辑

- - - +
+ + +
+
+ + +
+
+ + +
-
- - +
+ +
-
-
+
+ +
+
+

已保存

+ +
+
+
diff --git a/admin.js b/admin.js index 2f47de2..7f1dd40 100644 --- a/admin.js +++ b/admin.js @@ -5,6 +5,15 @@ const codeInput = document.getElementById("code-input"); const saveBtn = document.getElementById("save-btn"); const exampleBtn = document.getElementById("example-btn"); const listEl = document.getElementById("list"); +const listCountEl = document.getElementById("list-count"); + +function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} const EXAMPLE = `void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; @@ -29,19 +38,26 @@ async function fetchList() { function renderList(items) { listEl.innerHTML = ""; + if (listCountEl) { + listCountEl.textContent = items.length ? `${items.length} 项` : ""; + } if (!items.length) { - listEl.innerHTML = "
暂无数据
"; + listEl.innerHTML = + '
暂无着色器,请在上方创建并保存。
'; return; } items.forEach((item) => { const row = document.createElement("div"); row.className = "item"; row.innerHTML = ` -
- ${item.name} -
${item.author || "unknown"} · ${item.id}
+
+ ${esc(item.name)} +
${esc(item.author || "unknown")} · ${esc(item.id)}
+
+
+ 下载 .glsl +
- `; row.querySelector("button").addEventListener("click", async () => { await fetch(`${API_BASE}/${encodeURIComponent(item.id)}`, { method: "DELETE" }); diff --git a/display.js b/display.js index 68ad638..bee7338 100644 --- a/display.js +++ b/display.js @@ -13,7 +13,27 @@ const backBtn = document.getElementById("back-btn"); const API_BASE = "/api/shaders"; -/** 串行补全缺失缩略图(每步独立 WebGL,用完即释放),避免多卡片同时建上下文 */ +function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function getVisitorId() { + try { + let v = localStorage.getItem("sv_vid"); + if (!v) { + v = crypto.randomUUID(); + localStorage.setItem("sv_vid", v); + } + return v; + } catch { + return "anon"; + } +} + const thumbBackfillQueue = []; const thumbBackfillQueuedIds = new Set(); const thumbBackfillFailUntil = new Map(); @@ -85,11 +105,9 @@ function replaceCardStageWithThumb(id, url) { 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; @@ -135,7 +153,6 @@ function toPortableGlsl(code) { .replace(/ANGLE_elem_ref\(([^)]+)\)/g, "$1") .replace(/(\d+\.\d+|\d+)f\b/g, "$1"); - // Keep only helper functions + mainImage block, drop struct/template leftovers. const mi = s.indexOf("void mainImage"); if (mi >= 0) { const head = s.slice(0, mi); @@ -267,7 +284,6 @@ function createTexture(gl, width, height, data) { } function createDefaultChannelTextures(gl, noiseSize = 256) { - // iChannel0: audio-like lookup texture (matches many shadertoy audio assumptions) const w0 = 512; const h0 = 2; const d0 = new Uint8Array(w0 * h0 * 4); @@ -275,14 +291,12 @@ function createDefaultChannelTextures(gl, noiseSize = 256) { 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)); - // row0: fft, row1: waveform 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; } - // iChannel1/2/3: tiling noise/grain-like textures (smaller in grid previews). const size = noiseSize; const makeNoise = (scaleA, scaleB) => { const d = new Uint8Array(size * size * 4); @@ -349,7 +363,6 @@ function computeHoverBufferSize(cssW, cssH) { } const previews = []; -/** Driven by scheduleRender(); detail + grid hover. */ let renderRafId = 0; function scheduleRender() { @@ -502,11 +515,28 @@ function startGridHover(previewState, card) { } function createPreviewCard(shader) { - const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader; + const { + id, + name, + author = "unknown", + views = 0, + likes = 0, + code, + thumbnailUrl, + userLiked = false, + } = shader; const card = document.createElement("article"); card.className = "card"; card.dataset.search = `${name} ${author}`.toLowerCase(); + const authorRaw = String(author || "").trim(); + const showAuthor = authorRaw && authorRaw.toLowerCase() !== "unknown"; + const authorHtml = showAuthor + ? `${escHtml(authorRaw)}` + : ""; + const likedClass = userLiked ? " is-liked" : ""; + const likedAria = userLiked ? "true" : "false"; + const visualInner = thumbnailUrl ? `` : `
暂无缩略图
正在尝试自动生成…
`; @@ -514,12 +544,21 @@ function createPreviewCard(shader) { card.dataset.shaderId = id; card.innerHTML = `
- ${name} + ${escHtml(name)}
${visualInner}
- by ${author} - views ${views} | likes ${likes} +
${authorHtml}
+
+ + + ${views} + + +
`; @@ -528,9 +567,49 @@ function createPreviewCard(shader) { name, code, card, + views, + likes, + userLiked, }; - card.addEventListener("click", () => openDetail(state)); + const likeBtn = card.querySelector(".stat-like"); + if (likeBtn) { + likeBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + if (likeBtn.classList.contains("is-liked") || likeBtn.disabled) return; + try { + const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/like`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ visitorId: getVisitorId() }), + }); + if (!res.ok) return; + const d = await res.json(); + const likesEl = likeBtn.querySelector(".js-likes"); + if (likesEl && d.likes != null) likesEl.textContent = String(d.likes); + state.likes = d.likes; + if (d.liked) { + likeBtn.classList.add("is-liked"); + likeBtn.setAttribute("aria-pressed", "true"); + likeBtn.disabled = true; + const hv = likeBtn.querySelector(".stat-ico-heart"); + if (hv) { + hv.setAttribute("fill", "currentColor"); + hv.removeAttribute("stroke"); + hv.removeAttribute("stroke-width"); + } + state.userLiked = true; + } + } catch (_) { + /* ignore */ + } + }); + } + + card.addEventListener("click", (e) => { + if (e.target.closest(".stat-like")) return; + openDetail(state); + }); card.addEventListener("mouseenter", () => { if (detailViewEl.classList.contains("active")) return; @@ -578,6 +657,7 @@ function closeDetail() { gl.deleteBuffer(vbo); channelTextures.forEach((tex) => gl.deleteTexture(tex)); detailRuntime = null; + if (detailFpsEl) detailFpsEl.textContent = "—"; } function openDetail(previewState) { @@ -645,6 +725,17 @@ function openDetail(previewState) { detailViewEl.classList.add("active"); detailViewEl.setAttribute("aria-hidden", "false"); scheduleRender(); + + void fetch(`${API_BASE}/${encodeURIComponent(previewState.id)}/view`, { method: "POST" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d || d.views == null) return; + const c = gridEl.querySelector(`[data-shader-id="${CSS.escape(previewState.id)}"]`); + const vEl = c && c.querySelector(".js-views"); + if (vEl) vEl.textContent = String(d.views); + previewState.views = d.views; + }) + .catch(() => {}); } function applySearch() { @@ -657,7 +748,10 @@ function applySearch() { async function loadShaders() { try { - const res = await fetch(`${API_BASE}?t=${Date.now()}`, { cache: "no-store" }); + const res = await fetch(`${API_BASE}?t=${Date.now()}`, { + cache: "no-store", + headers: { "X-Visitor-Id": getVisitorId() }, + }); if (!res.ok) throw new Error("加载失败"); const shaders = await res.json(); const signature = JSON.stringify( @@ -685,7 +779,7 @@ function syncStats() { statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${ paused ? "已暂停" : "运行中" }`; - shaderCountEl.textContent = `${previews.length} shaders`; + shaderCountEl.textContent = String(previews.length); } function renderAll(ts) { @@ -699,7 +793,7 @@ function renderAll(ts) { lastSec = Math.floor(elapsed); fps = fpsCounter; fpsCounter = 0; - globalFpsEl.textContent = `FPS: ${fps}`; + globalFpsEl.textContent = String(fps); } else { fpsCounter += 1; } @@ -785,7 +879,7 @@ function renderAll(ts) { detailLastSec = Math.floor(elapsed); detailFps = detailFpsCounter; detailFpsCounter = 0; - detailFpsEl.textContent = `FPS: ${detailFps}`; + detailFpsEl.textContent = String(detailFps); } else { detailFpsCounter += 1; } diff --git a/docker-compose.yml b/docker-compose.yml index 201118f..dd619f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,5 +13,5 @@ services: - PORT=5180 - NODE_ENV=production volumes: - - ./data:/app/data + - "${HOST_DATA_DIR:-./data}:/app/data" restart: unless-stopped diff --git a/index.html b/index.html index 0b8a61d..555bae4 100644 --- a/index.html +++ b/index.html @@ -3,35 +3,77 @@ - VFX 快速预览工具台 + Shadervault — GLSL 展示 +
-
- Featured Shader Playlist -
- 0 shaders - - - FPS: -- +
+

精选着色器

+

悬停实时预览,点击进入全屏。

+
+ +
+
+ 0 个着色器 + + + FPS
+
准备就绪
- -
+ +
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 8f443bd..1f2e52e 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -759,6 +759,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sql.js": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz", + "integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package-lock.json b/package-lock.json index 3de82ed..01bf6a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "express": "^5.2.1" + "express": "^5.2.1", + "sql.js": "^1.11.0" } }, "node_modules/accepts": { @@ -767,6 +768,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sql.js": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz", + "integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 94ee190..55b13eb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { - "express": "^5.2.1" + "express": "^5.2.1", + "sql.js": "^1.11.0" } } diff --git a/server.js b/server.js index 3be2e0d..94b55fe 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const express = require("express"); const fs = require("fs/promises"); const path = require("path"); const crypto = require("crypto"); +const statsDb = require("./stats-db"); const app = express(); const PORT = process.env.PORT || 5180; @@ -37,6 +38,11 @@ async function thumbFileExists(id) { } } +function downloadFilenameBase(name) { + const s = String(name || "shader").trim() || "shader"; + return s.replace(/[\\/:*?"<>|]/g, "_").slice(0, 120); +} + function decodePngBase64(s) { if (!s || typeof s !== "string") return null; const t = s.trim(); @@ -333,7 +339,6 @@ 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) => { const raw = String(item.code || ""); const { error, normalized } = normalizeIncomingCode(raw); @@ -343,13 +348,15 @@ app.get("/api/shaders", async (_req, res) => { if (isAngleLike(raw)) next.sourceFormat = "angle-metal-auto-converted"; return next; }); + const visitorId = String(_req.get("x-visitor-id") || "").trim(); 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 }; + const merged = statsDb.mergeItem({ ...item, thumbnailUrl }, visitorId); + return merged; }) ); res.json(out); @@ -358,6 +365,31 @@ app.get("/api/shaders", async (_req, res) => { } }); +app.get("/api/shaders/:id/download", async (req, res) => { + try { + const shaders = await readDb(); + const item = shaders.find((it) => it.id === req.params.id); + if (!item) { + return res.status(404).json({ error: "未找到该 shader" }); + } + const raw = + item.sourceGlsl != null && String(item.sourceGlsl).length > 0 + ? String(item.sourceGlsl) + : String(item.code || ""); + const base = downloadFilenameBase(item.name); + const fullName = `${base}.glsl`; + const asciiName = `${base.replace(/[^\x20-\x7e]/g, "_")}.glsl`; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fullName)}` + ); + res.send(raw); + } catch { + res.status(500).json({ error: "导出失败" }); + } +}); + app.post("/api/shaders", async (req, res) => { const { name, author = "unknown", code } = req.body || {}; if (!name || typeof name !== "string") { @@ -370,11 +402,13 @@ app.post("/api/shaders", async (req, res) => { try { const shaders = await readDb(); + const sourceGlsl = typeof code === "string" ? code : ""; const item = { id: crypto.randomUUID(), name: name.trim(), author: String(author || "unknown").trim() || "unknown", code: normalized, + sourceGlsl, views: Math.floor(3000 + Math.random() * 22000), likes: Math.floor(40 + Math.random() * 700), createdAt: new Date().toISOString(), @@ -382,12 +416,45 @@ app.post("/api/shaders", async (req, res) => { }; shaders.unshift(item); await writeDb(shaders); - res.status(201).json(item); + await statsDb.insertStats(item.id, item.views, item.likes); + res.status(201).json(statsDb.mergeItem(item, "")); } catch { res.status(500).json({ error: "保存失败" }); } }); +app.post("/api/shaders/:id/view", async (req, res) => { + try { + const shaders = await readDb(); + const item = shaders.find((it) => it.id === req.params.id); + if (!item) { + return res.status(404).json({ error: "未找到该 shader" }); + } + const row = await statsDb.incrementView(item.id, item); + res.json({ views: row.views, likes: row.likes }); + } catch { + res.status(500).json({ error: "更新失败" }); + } +}); + +app.post("/api/shaders/:id/like", async (req, res) => { + const visitorId = String((req.body && req.body.visitorId) || "").trim(); + if (!visitorId) { + return res.status(400).json({ error: "visitorId 必填" }); + } + try { + const shaders = await readDb(); + const item = shaders.find((it) => it.id === req.params.id); + if (!item) { + return res.status(404).json({ error: "未找到该 shader" }); + } + const out = await statsDb.tryLike(item.id, visitorId, item); + res.json(out); + } catch { + res.status(500).json({ error: "点赞失败" }); + } +}); + app.get("/api/shaders/:id/thumbnail", async (req, res) => { try { const p = thumbPath(req.params.id); @@ -438,6 +505,7 @@ app.delete("/api/shaders/:id", async (req, res) => { return res.status(404).json({ error: "未找到该 shader" }); } await writeDb(next); + await statsDb.deleteStats(req.params.id); await ensureThumbnailsDir(); try { await fs.unlink(thumbPath(req.params.id)); @@ -450,11 +518,20 @@ app.delete("/api/shaders/:id", async (req, res) => { } }); -ensureDb().then(async () => { +async function start() { + await ensureDb(); + await statsDb.load(); + const initialList = await readDb(); + await statsDb.migrateIfEmpty(initialList); try { await autoNormalizeStoredShaders(); } catch (_) {} app.listen(PORT, () => { console.log(`Server running: http://localhost:${PORT}`); }); +} + +start().catch((err) => { + console.error(err); + process.exit(1); }); diff --git a/start-docker-cn.sh b/start-docker-cn.sh index e078dbf..4696e7e 100755 --- a/start-docker-cn.sh +++ b/start-docker-cn.sh @@ -4,6 +4,22 @@ set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${PROJECT_DIR}" +usage() { + echo "用法: $0 [端口]" + echo " 端口: 可选,映射到本机 HTTP(容器内固定 5180)。也可用环境变量 HOST_PORT。" + echo " 数据: 宿主机目录通过 HOST_DATA_DIR 指定,默认 <项目目录>/data,与容器 /app/data 绑定,避免重建容器丢数据。" + echo "示例:" + echo " $0 # 交互输入端口,默认 5180" + echo " $0 8080 # 使用 8080" + echo " HOST_PORT=3000 $0" + echo " HOST_DATA_DIR=$HOME/vfxdemo-data $0 9000" +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + if ! command -v docker >/dev/null 2>&1; then echo "未检测到 docker,请先安装 Docker Desktop 或 Docker Engine。" exit 1 @@ -26,26 +42,49 @@ fi export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 -find_free_port() { - local port="$1" - while lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; do - port=$((port + 1)) - done - echo "${port}" +resolve_port() { + if [ -n "${1:-}" ]; then + printf '%s' "$1" + return + fi + if [ -n "${HOST_PORT:-}" ]; then + printf '%s' "${HOST_PORT}" + return + fi + if [ -t 0 ]; then + read -r -p "映射到本机的 HTTP 端口 [5180]: " _p + printf '%s' "${_p:-5180}" + return + fi + printf '%s' "5180" } -HOST_PORT="$(find_free_port 5180)" +HOST_PORT="$(resolve_port "${1:-}")" +if ! [[ "$HOST_PORT" =~ ^[0-9]+$ ]] || [ "$HOST_PORT" -lt 1 ] || [ "$HOST_PORT" -gt 65535 ]; then + echo "无效端口: ${HOST_PORT}" + exit 1 +fi export HOST_PORT -echo "==> 使用国内镜像源构建并启动 VFXdemo" -echo " - Node 基础镜像: docker.m.daocloud.io" -echo " - npm registry: registry.npmmirror.com" -echo " - Host 端口: ${HOST_PORT}" +DATA_DEFAULT="${PROJECT_DIR}/data" +export HOST_DATA_DIR="${HOST_DATA_DIR:-${DATA_DEFAULT}}" +mkdir -p "${HOST_DATA_DIR}" +export HOST_DATA_DIR="$(cd "${HOST_DATA_DIR}" && pwd)" + +if command -v lsof >/dev/null 2>&1; then + if lsof -nP -iTCP:"${HOST_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then + echo "警告: 本机端口 ${HOST_PORT} 已被占用,compose 可能启动失败。请换端口: $0 <端口>" + fi +fi + +echo "==> VFXdemo(国内镜像源构建)" +echo " 本机端口: ${HOST_PORT} -> 容器 5180" +echo " 数据目录: ${HOST_DATA_DIR} -> /app/data" "${COMPOSE_CMD[@]}" build --pull "${COMPOSE_CMD[@]}" up -d echo -echo "启动完成: http://localhost:${HOST_PORT}" -echo "查看日志: ${COMPOSE_CMD[*]} logs -f" -echo "停止服务: ${COMPOSE_CMD[*]} down" +echo "访问: http://localhost:${HOST_PORT}" +echo "日志: ${COMPOSE_CMD[*]} logs -f" +echo "停止: ${COMPOSE_CMD[*]} down" diff --git a/stats-db.js b/stats-db.js new file mode 100644 index 0000000..d016409 --- /dev/null +++ b/stats-db.js @@ -0,0 +1,155 @@ +const fs = require("fs/promises"); +const path = require("path"); +const initSqlJs = require("sql.js"); + +const STATS_PATH = path.join(__dirname, "data", "stats.sqlite"); + +let db = null; + +async function load() { + const SQL = await initSqlJs(); + let buf; + try { + buf = await fs.readFile(STATS_PATH); + } catch { + buf = undefined; + } + db = buf ? new SQL.Database(buf) : new SQL.Database(); + db.run(` + CREATE TABLE IF NOT EXISTS shader_stats ( + id TEXT PRIMARY KEY NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + likes INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS shader_likes ( + shader_id TEXT NOT NULL, + visitor_id TEXT NOT NULL, + PRIMARY KEY (shader_id, visitor_id) + ); + `); +} + +async function persist() { + const data = db.export(); + await fs.writeFile(STATS_PATH, Buffer.from(data)); +} + +async function migrateIfEmpty(list) { + const r = db.exec("SELECT COUNT(*) FROM shader_stats"); + const n = r.length && r[0].values.length ? r[0].values[0][0] : 0; + if (n > 0) return; + const stmt = db.prepare( + "INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)" + ); + for (const item of list) { + stmt.run([ + item.id, + Number(item.views) || 0, + Number(item.likes) || 0, + ]); + } + stmt.free(); + await persist(); +} + +function getStatsRow(id) { + const stmt = db.prepare("SELECT views, likes FROM shader_stats WHERE id = ?"); + stmt.bind([id]); + if (!stmt.step()) { + stmt.free(); + return null; + } + const o = stmt.getAsObject(); + stmt.free(); + return { views: o.views, likes: o.likes }; +} + +function mergeItem(item, visitorId) { + const row = getStatsRow(item.id); + const views = row ? row.views : Number(item.views) || 0; + const likes = row ? row.likes : Number(item.likes) || 0; + let userLiked = false; + if (visitorId) { + const s = db.prepare( + "SELECT 1 FROM shader_likes WHERE shader_id = ? AND visitor_id = ?" + ); + s.bind([item.id, visitorId]); + userLiked = s.step(); + s.free(); + } + return { ...item, views, likes, userLiked }; +} + +async function incrementView(id, fallback) { + const row = getStatsRow(id); + if (!row) { + db.run("INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [ + id, + (Number(fallback.views) || 0) + 1, + Number(fallback.likes) || 0, + ]); + } else { + db.run("UPDATE shader_stats SET views = views + 1 WHERE id = ?", [id]); + } + await persist(); + return getStatsRow(id); +} + +async function tryLike(id, visitorId, fallback) { + const chk = db.prepare( + "SELECT 1 FROM shader_likes WHERE shader_id = ? AND visitor_id = ?" + ); + chk.bind([id, visitorId]); + if (chk.step()) { + chk.free(); + const r = getStatsRow(id); + return { + likes: r ? r.likes : Number(fallback.likes) || 0, + liked: false, + }; + } + chk.free(); + + db.run("INSERT INTO shader_likes (shader_id, visitor_id) VALUES (?, ?)", [ + id, + visitorId, + ]); + + const existing = getStatsRow(id); + if (!existing) { + db.run("INSERT INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [ + id, + Number(fallback.views) || 0, + (Number(fallback.likes) || 0) + 1, + ]); + } else { + db.run("UPDATE shader_stats SET likes = likes + 1 WHERE id = ?", [id]); + } + await persist(); + return { likes: getStatsRow(id).likes, liked: true }; +} + +async function insertStats(id, views, likes) { + db.run("INSERT OR REPLACE INTO shader_stats (id, views, likes) VALUES (?, ?, ?)", [ + id, + views, + likes, + ]); + await persist(); +} + +async function deleteStats(id) { + db.run("DELETE FROM shader_likes WHERE shader_id = ?", [id]); + db.run("DELETE FROM shader_stats WHERE id = ?", [id]); + await persist(); +} + +module.exports = { + load, + migrateIfEmpty, + mergeItem, + incrementView, + tryLike, + insertStats, + deleteStats, +}; diff --git a/thumb-renderer.js b/thumb-renderer.js index de80ac9..8d7e346 100644 --- a/thumb-renderer.js +++ b/thumb-renderer.js @@ -1,12 +1,7 @@ -/** - * 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, ]; @@ -276,11 +271,6 @@ void main() { mainImage(outColor, gl_FragCoord.xy); }`; 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;