- Featured Shader Playlist
-
- 0 shaders
-
-
- FPS: --
+
+
+
+ 精选着色器
+悬停实时预览,点击进入全屏。
+
+
+
+ 0 个着色器
+
+
+ FPS —
准备就绪
-
-
+
+
- } data:image/png;base64,...
- */
window.captureShaderThumbnail = function captureShaderThumbnail(code, name) {
const canvas = document.createElement("canvas");
canvas.width = THUMB_W;
+
特效详情
- FPS: --
+ 实时 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