fix:修复bug

This commit is contained in:
Daniel
2026-04-01 20:29:33 +08:00
parent afbcd99224
commit a80d2b8430
9 changed files with 477 additions and 179 deletions

View File

@@ -11,6 +11,9 @@ npm run dev
打开 `http://localhost:5180`
- 展示页:`http://localhost:5180/index.html`
- 管理页:`http://localhost:5180/admin.html`
## Docker 一键启动(国内镜像)
```bash
@@ -20,13 +23,14 @@ npm run dev
说明:
- Node 基础镜像走 `docker.m.daocloud.io`
- npm 安装源走 `https://registry.npmmirror.com`
- 服务地址:`http://localhost:5180`
- 默认使用 `5180`,若被占用会自动递增到可用端口(如 `5181`
- 启动完成后终端会打印最终访问地址
## 页面能力
- 预览墙:多个 VFX 同屏实时渲染,支持点进详情
- 每个小窗独立播放/暂停
- 后端管理:点击“管理内容”,可直接粘贴 GLSL 并保存到后端
- 管理页独立:在 `admin.html` 粘贴 GLSL 并保存到后端
- 保存后实时刷新前端展示,支持删除
## 兼容的 uniform

83
admin.html Normal file
View File

@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shader 管理后台</title>
<style>
body {
margin: 0;
background: #0b0f18;
color: #e8ecff;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 20px;
}
.card {
border: 1px solid rgba(126, 165, 255, 0.35);
background: #111726;
border-radius: 12px;
padding: 14px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
input, textarea, button {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 8px;
}
input, textarea {
width: 100%;
padding: 8px;
}
textarea {
grid-column: 1 / -1;
min-height: 240px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace;
}
button {
padding: 8px 12px;
cursor: pointer;
}
.list { margin-top: 12px; }
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px 4px;
}
.item:first-child { border-top: 0; }
</style>
</head>
<body>
<div class="wrap">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h2 style="margin:0;">Shader 管理后台</h2>
<a href="./index.html" style="color:#a9c5ff;">返回展示页</a>
</div>
<div class="card">
<div class="grid">
<input id="name-input" placeholder="名称" />
<input id="author-input" placeholder="作者(可选)" />
<textarea id="code-input" placeholder="粘贴 GLSL必须包含 mainImage"></textarea>
</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<button id="save-btn">保存</button>
<button id="example-btn">填入示例</button>
</div>
</div>
<div class="card list" id="list"></div>
</div>
<script src="./admin.js"></script>
</body>
</html>

96
admin.js Normal file
View File

@@ -0,0 +1,96 @@
const API_BASE = "/api/shaders";
const nameInput = document.getElementById("name-input");
const authorInput = document.getElementById("author-input");
const codeInput = document.getElementById("code-input");
const saveBtn = document.getElementById("save-btn");
const exampleBtn = document.getElementById("example-btn");
const listEl = document.getElementById("list");
const EXAMPLE = `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 * 9.0 + t * 2.0) + cos(uv.y * 8.0 - t * 1.4);
vec3 col = 0.5 + 0.5 * cos(vec3(0.2, 1.2, 2.2) + v + t);
fragColor = vec4(col, 1.0);
}`;
function validateClientCode(code) {
if (!code.includes("mainImage")) return "代码需包含 mainImage。";
return "";
}
async function fetchList() {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error("加载失败");
return res.json();
}
function renderList(items) {
listEl.innerHTML = "";
if (!items.length) {
listEl.innerHTML = "<div>暂无数据</div>";
return;
}
items.forEach((item) => {
const row = document.createElement("div");
row.className = "item";
row.innerHTML = `
<div>
<strong>${item.name}</strong>
<div style="font-size:12px;color:#9fb2df;">${item.author || "unknown"} · ${item.id}</div>
</div>
<button>删除</button>
`;
row.querySelector("button").addEventListener("click", async () => {
await fetch(`${API_BASE}/${encodeURIComponent(item.id)}`, { method: "DELETE" });
await reload();
});
listEl.appendChild(row);
});
}
async function reload() {
const items = await fetchList();
renderList(items);
}
saveBtn.addEventListener("click", async () => {
const name = nameInput.value.trim();
const author = authorInput.value.trim();
const code = codeInput.value.trim();
if (!name) {
alert("名称必填");
return;
}
const err = validateClientCode(code);
if (err) {
alert(err);
return;
}
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, author, code }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
alert(payload.error || "保存失败");
return;
}
const payload = await res.json().catch(() => ({}));
if (payload.sourceFormat === "angle-metal-auto-converted") {
alert("已自动解构并转换为 GLSL展示页可直接渲染。");
}
nameInput.value = "";
authorInput.value = "";
codeInput.value = "";
await reload();
});
exampleBtn.addEventListener("click", () => {
nameInput.value = "New Shader";
authorInput.value = "you";
codeInput.value = EXAMPLE;
});
reload();

File diff suppressed because one or more lines are too long

View File

@@ -10,15 +10,6 @@ const detailTitleEl = document.getElementById("detail-title");
const detailFpsEl = document.getElementById("detail-fps");
const detailCanvas = document.getElementById("detail-canvas");
const backBtn = document.getElementById("back-btn");
const manageBtn = document.getElementById("manage-btn");
const manageModal = document.getElementById("manage-modal");
const closeManageBtn = document.getElementById("close-manage-btn");
const saveShaderBtn = document.getElementById("save-shader-btn");
const fillExampleBtn = document.getElementById("fill-example-btn");
const shaderNameInput = document.getElementById("shader-name-input");
const shaderAuthorInput = document.getElementById("shader-author-input");
const shaderCodeInput = document.getElementById("shader-code-input");
const shaderAdminList = document.getElementById("shader-admin-list");
const API_BASE = "/api/shaders";
@@ -27,13 +18,63 @@ precision highp float;
layout(location = 0) in vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
const EXAMPLE_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
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");
// 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);
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 * 10.0 + t * 2.1) + cos(uv.y * 9.0 - t * 1.4);
vec3 col = 0.5 + 0.5 * cos(vec3(0.1, 1.1, 2.3) + v + t);
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
@@ -73,7 +114,6 @@ function compileProgram(gl, fragmentShaderSource) {
}
return shader;
}
const vs = compile(gl.VERTEX_SHADER, vertexShaderSource);
const fs = compile(gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = gl.createProgram();
@@ -91,7 +131,6 @@ function compileProgram(gl, fragmentShaderSource) {
}
const previews = [];
const sourceShaders = [];
let paused = false;
let elapsed = 0;
let frame = 0;
@@ -104,6 +143,7 @@ let detailFps = 0;
let detailLastSec = 0;
let detailRuntime = null;
let searchKeyword = "";
let lastSignature = "";
function createPreviewCard(shader) {
const { id, name, author = "unknown", views = 0, likes = 0, code } = shader;
@@ -115,7 +155,6 @@ function createPreviewCard(shader) {
<strong>${name}</strong>
<div style="display:flex; gap:6px;">
<button type="button" class="pause-local-btn">暂停</button>
<button type="button" class="remove-btn">删除</button>
</div>
</div>
<canvas></canvas>
@@ -129,7 +168,6 @@ function createPreviewCard(shader) {
const canvas = card.querySelector("canvas");
const errorEl = card.querySelector(".error");
const pauseLocalBtn = card.querySelector(".pause-local-btn");
const removeBtn = card.querySelector(".remove-btn");
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
if (!gl) {
errorEl.textContent = "当前浏览器不支持 WebGL2";
@@ -137,12 +175,25 @@ function createPreviewCard(shader) {
}
let program;
let codeForRender = code;
try {
program = compileProgram(gl, buildFragmentShader(code));
} catch (err) {
errorEl.textContent = `编译失败:\n${String(err.message || err)}`;
program = compileProgram(gl, buildFragmentShader(codeForRender));
} catch (_err1) {
try {
codeForRender = toPortableGlsl(code);
program = compileProgram(gl, buildFragmentShader(codeForRender));
errorEl.textContent = "已自动兼容转换后渲染";
} catch (_err2) {
codeForRender = fallbackShader(name);
try {
program = compileProgram(gl, buildFragmentShader(codeForRender));
errorEl.textContent = "原始代码不兼容,已使用兼容预览模式";
} catch (err3) {
errorEl.textContent = `编译失败:\n${String(err3.message || err3)}`;
return;
}
}
}
const { vao } = createQuad(gl);
const uniforms = {
@@ -157,6 +208,7 @@ function createPreviewCard(shader) {
id,
name,
code,
codeForRender,
card,
canvas,
gl,
@@ -168,7 +220,6 @@ function createPreviewCard(shader) {
localTime: 0,
localFrame: 0,
lastTick: performance.now(),
removed: false,
};
canvas.addEventListener("mousemove", (event) => {
@@ -185,10 +236,6 @@ function createPreviewCard(shader) {
state.mouse.down = false;
});
removeBtn.addEventListener("click", (event) => {
event.stopPropagation();
deleteShader(id);
});
pauseLocalBtn.addEventListener("click", (event) => {
event.stopPropagation();
state.isPlaying = !state.isPlaying;
@@ -206,13 +253,6 @@ function clearPreviews() {
previews.length = 0;
}
function renderCards(shaders) {
clearPreviews();
shaders.forEach(createPreviewCard);
applySearch();
syncStats();
}
function closeDetail() {
detailViewEl.classList.remove("active");
detailViewEl.setAttribute("aria-hidden", "true");
@@ -228,15 +268,14 @@ function openDetail(previewState) {
closeDetail();
const gl = detailCanvas.getContext("webgl2", { antialias: false, alpha: false });
if (!gl) return;
let program;
const detailCode = previewState.codeForRender || previewState.code;
try {
program = compileProgram(gl, buildFragmentShader(previewState.code));
program = compileProgram(gl, buildFragmentShader(detailCode));
} catch (err) {
statsEl.textContent = `详情编译失败: ${String(err.message || err)}`;
return;
}
const { vao, vbo } = createQuad(gl);
detailRuntime = {
gl,
@@ -252,12 +291,45 @@ function openDetail(previewState) {
},
mouse: { x: 0, y: 0, downX: 0, downY: 0, down: false },
};
detailTitleEl.textContent = previewState.name;
detailViewEl.classList.add("active");
detailViewEl.setAttribute("aria-hidden", "false");
}
function applySearch() {
const keyword = searchKeyword.trim().toLowerCase();
for (const preview of previews) {
preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none";
}
}
async function loadShaders() {
try {
const res = await fetch(`${API_BASE}?t=${Date.now()}`, { cache: "no-store" });
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 }))
);
if (signature !== lastSignature) {
lastSignature = signature;
clearPreviews();
shaders.forEach(createPreviewCard);
applySearch();
syncStats();
}
} catch (err) {
statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
}
}
function syncStats() {
statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${
paused ? "已暂停" : "运行中"
}`;
shaderCountEl.textContent = `${previews.length} shaders`;
}
function renderAll(ts) {
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
@@ -265,7 +337,6 @@ function renderAll(ts) {
elapsed += dt;
frame += 1;
}
if (Math.floor(elapsed) !== lastSec) {
lastSec = Math.floor(elapsed);
fps = fpsCounter;
@@ -284,7 +355,6 @@ function renderAll(ts) {
preview.localTime += localDt;
preview.localFrame += 1;
}
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(rect.width * dpr));
@@ -294,7 +364,6 @@ function renderAll(ts) {
canvas.height = h;
gl.viewport(0, 0, w, h);
}
gl.useProgram(program);
gl.uniform3f(uniforms.iResolution, canvas.width, canvas.height, 1);
gl.uniform1f(uniforms.iTime, preview.localTime);
@@ -337,7 +406,6 @@ function renderAll(ts) {
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
if (Math.floor(elapsed) !== detailLastSec) {
detailLastSec = Math.floor(elapsed);
detailFps = detailFpsCounter;
@@ -351,98 +419,11 @@ function renderAll(ts) {
requestAnimationFrame(renderAll);
}
function syncStats() {
statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${
paused ? "已暂停" : "运行中"
}`;
shaderCountEl.textContent = `${previews.length} shaders`;
}
function applySearch() {
const keyword = searchKeyword.trim().toLowerCase();
for (const preview of previews) {
preview.card.style.display = !keyword || preview.card.dataset.search.includes(keyword) ? "" : "none";
}
}
function renderAdminList() {
shaderAdminList.innerHTML = "";
if (sourceShaders.length === 0) {
shaderAdminList.innerHTML = `<div class="row-item"><span>暂无 shader点击“保存到后端”添加。</span></div>`;
return;
}
sourceShaders.forEach((shader) => {
const row = document.createElement("div");
row.className = "row-item";
row.innerHTML = `
<div>
<strong>${shader.name}</strong>
<div style="font-size:12px;color:#9ea6c8;">${shader.author || "unknown"} · ${shader.id}</div>
</div>
<button type="button">删除</button>
`;
row.querySelector("button").addEventListener("click", () => deleteShader(shader.id));
shaderAdminList.appendChild(row);
});
}
async function fetchShaders() {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error("加载失败");
return res.json();
}
async function loadShaders() {
try {
const list = await fetchShaders();
sourceShaders.splice(0, sourceShaders.length, ...list);
renderCards(sourceShaders);
renderAdminList();
} catch (err) {
statsEl.textContent = `读取后端失败:${String(err.message || err)}`;
}
}
async function saveShader() {
const name = shaderNameInput.value.trim();
const author = shaderAuthorInput.value.trim();
const code = shaderCodeInput.value.trim();
if (!name || !code.includes("mainImage")) {
statsEl.textContent = "保存失败:名称必填,代码需包含 mainImage。";
return;
}
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, author, code }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload.error || "保存失败");
}
shaderNameInput.value = "";
shaderAuthorInput.value = "";
shaderCodeInput.value = "";
await loadShaders();
}
async function deleteShader(id) {
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) {
statsEl.textContent = "删除失败。";
return;
}
await loadShaders();
}
pauseBtn.addEventListener("click", () => {
paused = !paused;
pauseBtn.textContent = paused ? "全局继续" : "全局暂停";
syncStats();
});
resetBtn.addEventListener("click", () => {
elapsed = 0;
frame = 0;
@@ -452,7 +433,6 @@ resetBtn.addEventListener("click", () => {
detailFpsCounter = 0;
syncStats();
});
backBtn.addEventListener("click", closeDetail);
detailCanvas.addEventListener("mousemove", (event) => {
if (!detailRuntime) return;
@@ -470,31 +450,11 @@ window.addEventListener("mouseup", () => {
if (!detailRuntime) return;
detailRuntime.mouse.down = false;
});
searchInput.addEventListener("input", (event) => {
searchKeyword = event.target.value || "";
applySearch();
});
manageBtn.addEventListener("click", () => {
manageModal.classList.add("active");
manageModal.setAttribute("aria-hidden", "false");
});
closeManageBtn.addEventListener("click", () => {
manageModal.classList.remove("active");
manageModal.setAttribute("aria-hidden", "true");
});
fillExampleBtn.addEventListener("click", () => {
shaderNameInput.value = "New Shader";
shaderAuthorInput.value = "you";
shaderCodeInput.value = EXAMPLE_SHADER;
});
saveShaderBtn.addEventListener("click", async () => {
try {
await saveShader();
} catch (err) {
statsEl.textContent = `保存失败:${String(err.message || err)}`;
}
});
loadShaders();
setInterval(loadShaders, 3000);
requestAnimationFrame(renderAll);

View File

@@ -8,7 +8,7 @@ services:
image: vfxdemo:cn
container_name: vfxdemo
ports:
- "5180:5180"
- "${HOST_PORT:-5180}:5180"
environment:
- PORT=5180
- NODE_ENV=production

View File

@@ -280,7 +280,7 @@
<div class="brand">Shadervault</div>
<div class="header-right">
<input id="search-input" class="search" placeholder="Search shaders..." />
<button id="manage-btn">管理内容</button>
<a href="./admin.html" style="text-decoration:none;"><button>管理后台</button></a>
</div>
</header>
<div id="app">
@@ -312,26 +312,6 @@
</div>
</section>
<section id="manage-modal" class="modal" aria-hidden="true">
<div class="modal-card">
<div class="topbar">
<strong>Shader 内容管理</strong>
<div style="display:flex; gap:8px">
<button id="close-manage-btn">关闭</button>
</div>
</div>
<div class="modal-grid">
<input id="shader-name-input" placeholder="名称" />
<input id="shader-author-input" placeholder="作者(可选)" />
<textarea id="shader-code-input" placeholder="粘贴 GLSL必须包含 mainImage"></textarea>
</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<button id="save-shader-btn">保存到后端</button>
<button id="fill-example-btn">填入示例</button>
</div>
<div class="table" id="shader-admin-list"></div>
</div>
</section>
<script src="./frontend.js"></script>
<script src="./display.js"></script>
</body>
</html>

View File

@@ -30,6 +30,90 @@ async function writeDb(list) {
await fs.writeFile(DB_PATH, JSON.stringify(list, null, 2), "utf8");
}
function extractFunctionSection(code) {
const endMarker = code.indexOf("void ANGLE__0_main");
const end = endMarker >= 0 ? endMarker : code.length;
const firstFn = code.search(
/(metal::float[234](x2)?|float|void)\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*\{/m
);
if (firstFn < 0 || firstFn >= end) return "";
return code.slice(firstFn, end);
}
function convertAngleMetalToGlsl(code) {
let section = extractFunctionSection(code);
if (!section || !section.includes("_umainImage")) return "";
section = section
.replace(
/void _umainImage\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*thread metal::float4\s*&\s*([A-Za-z0-9_]+)\s*,\s*metal::float2\s*([A-Za-z0-9_]+)\s*\)/g,
"void _umainImage(out vec4 $1, vec2 $2)"
)
.replace(
/float _umap\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*\)/g,
"float _umap(vec3 $1)"
)
.replace(
/metal::float4 _urm\s*\(\s*constant ANGLE_UserUniforms\s*&\s*ANGLE_userUniforms\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*,\s*metal::float3\s*([A-Za-z0-9_]+)\s*\)/g,
"vec4 _urm(vec3 $1, vec3 $2)"
)
.replace(/_umap\s*\(\s*ANGLE_userUniforms\s*,/g, "_umap(")
.replace(/_urm\s*\(\s*ANGLE_userUniforms\s*,/g, "_urm(")
.replace(/ANGLE_userUniforms\._uiResolution/g, "iResolution")
.replace(/ANGLE_userUniforms\._uiTime/g, "iTime")
.replace(/metal::fast::normalize/g, "normalize")
.replace(/metal::/g, "")
.replace(/\bthread\b/g, "")
.replace(/\bconstant\b/g, "")
.replace(/\buint32_t\b/g, "uint")
.replace(/;\s*;/g, ";");
const cleaned = [
"void ANGLE_loopForwardProgress() {}",
section,
"void mainImage(out vec4 fragColor, in vec2 fragCoord) { _umainImage(fragColor, fragCoord); }",
].join("\n");
return cleaned;
}
function normalizeIncomingCode(code) {
if (!code || typeof code !== "string") return { error: "code 不能为空", normalized: "" };
if (code.includes("metal::") || code.includes("[[function_constant")) {
const converted = convertAngleMetalToGlsl(code);
if (!converted) {
return { error: "自动解构失败:请检查粘贴内容是否完整。", normalized: "" };
}
return { error: "", normalized: converted };
}
if (!code.includes("mainImage")) {
return { error: "code 必须包含 mainImage", normalized: "" };
}
return { error: "", normalized: code };
}
async function autoNormalizeStoredShaders() {
const shaders = await readDb();
let changed = false;
const next = shaders.map((item) => {
const code = String(item.code || "");
if (code.includes("metal::") || code.includes("[[function_constant")) {
const { error, normalized } = normalizeIncomingCode(code);
if (!error && normalized) {
changed = true;
return {
...item,
code: normalized,
updatedAt: new Date().toISOString(),
sourceFormat: "angle-metal-auto-converted",
};
}
}
return item;
});
if (changed) await writeDb(next);
}
app.get("/api/shaders", async (_req, res) => {
try {
const shaders = await readDb();
@@ -44,8 +128,9 @@ app.post("/api/shaders", async (req, res) => {
if (!name || typeof name !== "string") {
return res.status(400).json({ error: "name 必填" });
}
if (!code || typeof code !== "string" || !code.includes("mainImage")) {
return res.status(400).json({ error: "code 必须包含 mainImage" });
const { error: parseError, normalized } = normalizeIncomingCode(code);
if (parseError) {
return res.status(400).json({ error: parseError });
}
try {
@@ -54,10 +139,11 @@ app.post("/api/shaders", async (req, res) => {
id: crypto.randomUUID(),
name: name.trim(),
author: String(author || "unknown").trim() || "unknown",
code,
code: normalized,
views: Math.floor(3000 + Math.random() * 22000),
likes: Math.floor(40 + Math.random() * 700),
createdAt: new Date().toISOString(),
sourceFormat: code.includes("metal::") ? "angle-metal-auto-converted" : "glsl",
};
shaders.unshift(item);
await writeDb(shaders);
@@ -82,6 +168,7 @@ app.delete("/api/shaders/:id", async (req, res) => {
});
ensureDb().then(() => {
autoNormalizeStoredShaders().catch(() => {});
app.listen(PORT, () => {
console.log(`Server running: http://localhost:${PORT}`);
});

View File

@@ -26,14 +26,26 @@ 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}"
}
HOST_PORT="$(find_free_port 5180)"
export HOST_PORT
echo "==> 使用国内镜像源构建并启动 VFXdemo"
echo " - Node 基础镜像: docker.m.daocloud.io"
echo " - npm registry: registry.npmmirror.com"
echo " - Host 端口: ${HOST_PORT}"
"${COMPOSE_CMD[@]}" build --pull
"${COMPOSE_CMD[@]}" up -d
echo
echo "启动完成: http://localhost:5180"
echo "启动完成: http://localhost:${HOST_PORT}"
echo "查看日志: ${COMPOSE_CMD[*]} logs -f"
echo "停止服务: ${COMPOSE_CMD[*]} down"