commit 0bf12999085a6eee1054ab186d72ce438c228715 Author: Daniel Date: Wed Apr 1 17:28:39 2026 +0800 feat:新增文件内容 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30601c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5e4086a --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de41c6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +ARG BASE_IMAGE=docker.m.daocloud.io/library/node:20-bookworm-slim +FROM ${BASE_IMAGE} + +ARG APT_MIRROR=mirrors.aliyun.com +ARG NPM_REGISTRY=https://registry.npmmirror.com + +RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json .npmrc ./ +RUN npm config set registry ${NPM_REGISTRY} \ + && npm install --registry=${NPM_REGISTRY} + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..de6ea8b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + args: + BASE_IMAGE: docker.m.daocloud.io/library/node:20-bookworm-slim + APT_MIRROR: mirrors.aliyun.com + NPM_REGISTRY: https://registry.npmmirror.com + container_name: ai-vfx-editor-mvp + environment: + - NPM_CONFIG_REGISTRY=https://registry.npmmirror.com + - CHOKIDAR_USEPOLLING=true + ports: + - "5173:5173" diff --git a/index.html b/index.html new file mode 100644 index 0000000..33653d5 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + AI VFX Editor MVP + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc08638 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "ai-vfx-editor-mvp", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.178.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.178.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4d63798 --- /dev/null +++ b/readme.md @@ -0,0 +1,216 @@ +# AI VFX Editor MVP - Coding Agent 执行说明 + +## 🎯 目标 + +构建一个最小可用的 AI 特效编辑器(Web版),支持: + +1. Three.js 实时渲染 Shader +2. 参数面板控制 Shader uniforms +3. 多个特效模板切换 +4. 简单 AI 输入生成初始参数 +5. 导出 Shader 和 Three.js Material + +--- + +## 🏗️ 技术栈 + +* Frontend: React + Vite +* 渲染: Three.js +* Shader: GLSL +* 状态管理: useState(无需复杂方案) +* AI接口: mock(先写死) + +--- + +## 📁 项目结构 + +src/ +├── App.tsx +├── components/ +│ ├── Preview.tsx // 渲染窗口 +│ ├── ControlPanel.tsx // 参数控制 +│ ├── ShaderEditor.tsx // shader展示 +│ ├── TemplateSelector.tsx // 模板选择 +├── engine/ +│ ├── useThree.ts // 初始化Three +│ ├── shaderBuilder.ts // shader拼装 +│ ├── uniformManager.ts // 参数管理 +├── shaders/ +│ ├── base.vert +│ ├── modules/ +│ │ ├── noise.glsl +│ │ ├── uv_scroll.glsl +│ │ ├── alpha.glsl +│ ├── templates/ +│ │ ├── fire.ts +│ │ ├── ripple.ts +│ │ ├── distortion.ts + +--- + +## 🎨 功能拆解 + +### 1️⃣ Preview(核心) + +* 使用 Three.js 创建 full-screen quad + +* 使用 ShaderMaterial + +* uniforms 包含: + + * u_time + * u_speed + * u_scale + +* requestAnimationFrame 更新 u_time + +--- + +### 2️⃣ ControlPanel + +* 使用 slider 控制: + + * speed + * scale + * intensity + +* 实时更新 uniforms + +--- + +### 3️⃣ ShaderBuilder(关键模块) + +实现函数: + +buildShader(template, params) + +逻辑: + +* 根据 template 注入模块: + + * noise + * uv scroll + * alpha + +* 输出完整 fragment shader + +--- + +### 4️⃣ 模板系统 + +每个模板定义: + +{ +name: "fire", +modules: ["noise", "uv_scroll", "alpha"], +defaultParams: { +speed: 1.0, +scale: 3.0 +} +} + +--- + +### 5️⃣ AI模块(Mock) + +实现函数: + +generateEffect(prompt) + +返回: + +{ +template: "fire", +params: { +speed: 1.5, +scale: 4.0 +} +} + +--- + +### 6️⃣ 导出功能(必须实现) + +按钮:Export + +导出内容: + +1. fragment.glsl +2. vertex.glsl +3. material.json + +--- + +## 🧪 测试标准 + +必须满足: + +* 参数修改实时生效(<16ms) +* 模板切换无报错 +* Shader 可复制运行 +* 导出文件可复用 + +--- + +## 🚀 初始任务顺序 + +1. 初始化 Vite + React +2. 完成 Three.js 渲染 +3. 加入 ShaderMaterial +4. 加入 uniforms 动画 +5. 实现 ControlPanel +6. 实现 模板系统 +7. 实现 shaderBuilder +8. 实现 Export + +--- + +## ❗ 注意事项 + +* 不要引入复杂状态管理 +* 不要实现粒子系统 +* 不要过度抽象 +* 优先保证“可运行” + +--- + +## 🎯 最终目标 + +得到一个: + +👉 可运行的 Web 特效编辑器 +👉 能导出 Shader +👉 能通过参数控制效果 + +这是后续商业化 POC 的基础 + +--- + +## 🐳 本地 Docker 快速启动(国内镜像) + +项目已默认切换到国内镜像源: + +* Node 基础镜像:`docker.m.daocloud.io` +* Debian APT:`mirrors.aliyun.com` +* NPM:`registry.npmmirror.com` + +### 1) 启动 + +```bash +chmod +x scripts/docker-up.sh scripts/docker-down.sh +./scripts/docker-up.sh +``` + +启动后访问:`http://localhost:5173` + +### 2) 停止 + +```bash +./scripts/docker-down.sh +``` + +### 3) 查看日志 + +```bash +docker compose logs -f web +``` diff --git a/scripts/docker-down.sh b/scripts/docker-down.sh new file mode 100755 index 0000000..db8ac3b --- /dev/null +++ b/scripts/docker-down.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${PROJECT_ROOT}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + echo "未找到 docker compose / docker-compose。" + exit 1 +fi + +echo "停止并清理容器..." +"${COMPOSE_CMD[@]}" down --remove-orphans +echo "已停止。" diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh new file mode 100755 index 0000000..3bb8c19 --- /dev/null +++ b/scripts/docker-up.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${PROJECT_ROOT}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + echo "未找到 docker compose / docker-compose,请先安装 Docker Desktop。" + exit 1 +fi + +echo "使用国内镜像源构建并启动容器..." +"${COMPOSE_CMD[@]}" build --pull +"${COMPOSE_CMD[@]}" up -d + +echo "启动完成。" +echo "访问地址: http://localhost:5173" +echo "查看日志: ${COMPOSE_CMD[*]} logs -f web" diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..179cd9f --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,280 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Preview from "./components/Preview"; +import { generateEffect } from "./engine/aiMock"; +import { buildShader } from "./engine/shaderBuilder"; +import { createUniforms, updateUniformValues } from "./engine/uniformManager"; +import baseVertexShader from "./shaders/base.vert?raw"; +import { templateMap, templates } from "./shaders/templates"; +import type { EffectParams } from "./shaders/templates/types"; + +function downloadFile(filename: string, content: string, type: string): void { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +type ShaderPass = { + id: string; + name: string; + fragment: string; + locked?: boolean; +}; + +const initialPasses: ShaderPass[] = templates.map((t) => ({ + id: t.name, + name: t.name, + fragment: buildShader(t, t.defaultParams), + locked: true +})); + +export default function App() { + const defaultTemplate = templates[0]; + const [templateName, setTemplateName] = useState(templates[0].name); + const [prompt, setPrompt] = useState("hot fire"); + const [passes, setPasses] = useState(initialPasses); + const [activePassId, setActivePassId] = useState(initialPasses[0].id); + const [params, setParams] = useState(defaultTemplate.defaultParams); + const [uniforms, setUniforms] = useState(() => createUniforms(defaultTemplate.defaultParams)); + const [appliedShader, setAppliedShader] = useState(initialPasses[0].fragment); + const [isPlaying, setIsPlaying] = useState(true); + const previewWrapRef = useRef(null); + + const template = templateMap.get(templateName) ?? templates[0]; + const activePass = useMemo( + () => passes.find((p) => p.id === activePassId) ?? passes[0], + [passes, activePassId] + ); + + useEffect(() => { + updateUniformValues(uniforms, isPlaying ? params : { ...params, speed: 0 }); + }, [uniforms, params, isPlaying]); + + const onParamChange = (key: keyof EffectParams, value: number) => { + const next = { ...params, [key]: value }; + setParams(next); + }; + + const onTemplateSelect = (name: string) => { + const selected = templateMap.get(name); + if (!selected) { + return; + } + setTemplateName(name); + setActivePassId(selected.name); + setAppliedShader(passes.find((p) => p.id === selected.name)?.fragment ?? appliedShader); + setParams(selected.defaultParams); + setUniforms(createUniforms(selected.defaultParams)); + }; + + const onGenerate = () => { + const result = generateEffect(prompt); + const selected = templateMap.get(result.template); + if (!selected) { + return; + } + setTemplateName(selected.name); + setActivePassId(selected.name); + setAppliedShader(passes.find((p) => p.id === selected.name)?.fragment ?? appliedShader); + setParams(result.params); + setUniforms(createUniforms(result.params)); + }; + + const onActiveShaderChange = (value: string) => { + setPasses((prev) => prev.map((p) => (p.id === activePass.id ? { ...p, fragment: value } : p))); + }; + + const onApplyShader = () => { + setAppliedShader(activePass.fragment); + }; + + const onAddShader = () => { + const customCount = passes.filter((p) => p.id.startsWith("custom-")).length + 1; + const id = `custom-${Date.now()}`; + const next: ShaderPass = { + id, + name: `custom_${customCount}`, + fragment: activePass.fragment + }; + setPasses((prev) => [...prev, next]); + setActivePassId(id); + setAppliedShader(next.fragment); + }; + + const onDeleteShader = () => { + if (!activePass || activePass.locked || passes.length <= 1) { + return; + } + const index = passes.findIndex((p) => p.id === activePass.id); + const nextPasses = passes.filter((p) => p.id !== activePass.id); + const fallback = nextPasses[Math.max(0, index - 1)]; + setPasses(nextPasses); + setActivePassId(fallback.id); + setAppliedShader(fallback.fragment); + }; + + const onTogglePlay = () => { + setIsPlaying((prev) => !prev); + }; + + const onFullscreen = () => { + previewWrapRef.current?.requestFullscreen?.(); + }; + + const onExport = () => { + downloadFile("fragment.glsl", appliedShader, "text/plain;charset=utf-8"); + downloadFile("vertex.glsl", baseVertexShader, "text/plain;charset=utf-8"); + downloadFile( + "material.json", + JSON.stringify( + { + type: "ShaderMaterial", + template: activePass.name, + uniforms: { + u_time: 0, + u_speed: params.speed, + u_scale: params.scale, + u_intensity: params.intensity + } + }, + null, + 2 + ), + "application/json;charset=utf-8" + ); + }; + + return ( +
+
+

AI VFX Shader Tool

+

按 Shadertoy 结构重排:预览 + 交互 + Shader 可新增/编辑。

+
+ +
+
+
+ +
+ + Active: {activePass.name} + +
+ + +
+
+
+ +
+
+ {passes.map((pass) => ( + + ))} + + +
+ +
+
+ + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d23557b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "baseUrl": "." + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..db1b19e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()] +});