fix:新增功能并优化

This commit is contained in:
Daniel
2026-04-02 11:15:21 +08:00
parent 0304805ce1
commit 8d69c0979c
13 changed files with 1283 additions and 214 deletions

View File

@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
}
/** 全屏详情:限制像素量以便稳定 50+ FPS过高 DPR × 边长会显著拖慢片元着色器) */
const DETAIL_MAX_PIXEL_RATIO = 1.35;
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
/** 卡片 hover 实时预览:单例 WebGL与缩略图同量级分辨率上限 */
const HOVER_MAX_PIXEL_RATIO = 1.25;
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
@@ -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
? `<span class="card-meta-author">${escHtml(authorRaw)}</span>`
: "";
const likedClass = userLiked ? " is-liked" : "";
const likedAria = userLiked ? "true" : "false";
const visualInner = thumbnailUrl
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
@@ -514,12 +544,21 @@ function createPreviewCard(shader) {
card.dataset.shaderId = id;
card.innerHTML = `
<div class="card-head">
<strong>${name}</strong>
<strong>${escHtml(name)}</strong>
</div>
<div class="card-stage">${visualInner}</div>
<div class="card-meta">
<span>by ${author}</span>
<span>views ${views} | likes ${likes}</span>
<div class="card-meta-left">${authorHtml}</div>
<div class="card-meta-stats">
<span class="stat-pill" title="浏览量">
<svg class="stat-ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="js-views">${views}</span>
</span>
<button type="button" class="stat-pill stat-like${likedClass}" data-like-id="${id}" aria-label="点赞" aria-pressed="${likedAria}" ${userLiked ? "disabled" : ""}>
<svg class="stat-ico stat-ico-heart" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${userLiked ? 'fill="currentColor" stroke="none"' : 'fill="none" stroke="currentColor" stroke-width="2"'} aria-hidden="true"><path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="js-likes">${likes}</span>
</button>
</div>
</div>
`;
@@ -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;
}