import { useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; 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 EffectItem = { id: string; name: string; templateName: string; params: EffectParams; shaderDraft: string; shaderApplied: string; }; function makeEffect(templateName: string, serial: number): EffectItem { const template = templateMap.get(templateName) ?? templates[0]; const shader = buildShader(template, template.defaultParams); return { id: `effect-${Date.now()}-${Math.floor(Math.random() * 10000)}`, name: `effect_${serial}`, templateName: template.name, params: { ...template.defaultParams }, shaderDraft: shader, shaderApplied: shader }; } export default function App() { const channelIndices = [0, 1, 2, 3] as const; const channelKeys = ["iChannel0", "iChannel1", "iChannel2", "iChannel3"] as const; const [effects, setEffects] = useState([makeEffect(templates[0].name, 1)]); const [activeEffectId, setActiveEffectId] = useState(""); const [prompt, setPrompt] = useState("hot fire"); const [isPlaying, setIsPlaying] = useState(true); const previewWrapRef = useRef(null); const activeEffect = useMemo(() => { const direct = effects.find((effect) => effect.id === activeEffectId); return direct ?? effects[0]; }, [effects, activeEffectId]); const [uniforms, setUniforms] = useState(() => createUniforms(effects[0].params)); const [channelTextures, setChannelTextures] = useState>([ null, null, null, null ]); const [channelFileNames, setChannelFileNames] = useState(["None", "None", "None", "None"]); const channelTexturesRef = useRef>([null, null, null, null]); useEffect(() => { if (!activeEffectId && effects[0]) { setActiveEffectId(effects[0].id); } }, [activeEffectId, effects]); useEffect(() => { if (activeEffect) { setUniforms(createUniforms(activeEffect.params)); } }, [activeEffect?.id]); useEffect(() => { if (!activeEffect) { return; } updateUniformValues(uniforms, isPlaying ? activeEffect.params : { ...activeEffect.params, speed: 0 }); }, [uniforms, activeEffect, isPlaying]); useEffect(() => { channelTexturesRef.current = channelTextures; channelIndices.forEach((idx) => { const channelKey = channelKeys[idx]; const texture = channelTextures[idx]; uniforms[channelKey].value = texture; if (texture && texture.image) { const image = texture.image as { width?: number; height?: number }; const width = image.width ?? 1; const height = image.height ?? 1; uniforms.iChannelResolution.value[idx].set(width, height, 1); } else { uniforms.iChannelResolution.value[idx].set(1, 1, 1); } }); }, [channelTextures, uniforms]); useEffect(() => { return () => { channelTexturesRef.current.forEach((texture) => texture?.dispose()); }; }, []); const updateActiveEffect = (updater: (current: EffectItem) => EffectItem) => { setEffects((prev) => prev.map((effect) => (effect.id === activeEffect.id ? updater(effect) : effect))); }; const onCreateEffect = () => { const next = makeEffect(templates[0].name, effects.length + 1); setEffects((prev) => [...prev, next]); setActiveEffectId(next.id); setUniforms(createUniforms(next.params)); }; const onDeleteEffect = () => { if (!activeEffect || effects.length <= 1) { return; } const currentIndex = effects.findIndex((effect) => effect.id === activeEffect.id); const nextEffects = effects.filter((effect) => effect.id !== activeEffect.id); const fallback = nextEffects[Math.max(0, currentIndex - 1)] ?? nextEffects[0]; setEffects(nextEffects); setActiveEffectId(fallback.id); setUniforms(createUniforms(fallback.params)); }; const onSelectTemplate = (templateName: string) => { const selected = templateMap.get(templateName); if (!selected) { return; } const nextShader = buildShader(selected, selected.defaultParams); updateActiveEffect((current) => ({ ...current, templateName: selected.name, params: { ...selected.defaultParams }, shaderDraft: nextShader, shaderApplied: nextShader })); setUniforms(createUniforms(selected.defaultParams)); }; const onGenerate = () => { const result = generateEffect(prompt); const selected = templateMap.get(result.template); if (!selected) { return; } const nextShader = buildShader(selected, result.params); updateActiveEffect((current) => ({ ...current, templateName: selected.name, params: { ...result.params }, shaderDraft: nextShader, shaderApplied: nextShader })); setUniforms(createUniforms(result.params)); }; const onParamChange = (key: keyof EffectParams, value: number) => { updateActiveEffect((current) => ({ ...current, params: { ...current.params, [key]: value } })); }; const onApplyShader = () => { updateActiveEffect((current) => ({ ...current, shaderApplied: current.shaderDraft })); }; const onExport = () => { downloadFile("fragment.glsl", activeEffect.shaderApplied, "text/plain;charset=utf-8"); downloadFile("vertex.glsl", baseVertexShader, "text/plain;charset=utf-8"); downloadFile( "material.json", JSON.stringify( { type: "ShaderMaterial", effect: activeEffect.name, template: activeEffect.templateName, uniforms: { u_time: 0, u_speed: activeEffect.params.speed, u_scale: activeEffect.params.scale, u_intensity: activeEffect.params.intensity } }, null, 2 ), "application/json;charset=utf-8" ); }; const onFullscreen = () => { previewWrapRef.current?.requestFullscreen?.(); }; const onUploadChannelTexture = (channelIndex: number, file: File) => { const objectUrl = URL.createObjectURL(file); const loader = new THREE.TextureLoader(); loader.load( objectUrl, (texture) => { texture.needsUpdate = true; setChannelTextures((prev) => { const next = [...prev]; next[channelIndex]?.dispose(); next[channelIndex] = texture; return next; }); setChannelFileNames((prev) => { const next = [...prev]; next[channelIndex] = file.name; return next; }); URL.revokeObjectURL(objectUrl); }, undefined, () => { URL.revokeObjectURL(objectUrl); } ); }; const onClearChannelTexture = (channelIndex: number) => { setChannelTextures((prev) => { const next = [...prev]; next[channelIndex]?.dispose(); next[channelIndex] = null; return next; }); setChannelFileNames((prev) => { const next = [...prev]; next[channelIndex] = "None"; return next; }); }; const onSampleRateChange = (value: number) => { uniforms.iSampleRate.value = Number.isFinite(value) && value > 0 ? value : 44100; }; return (

AI VFX Shader Tool

Active Effect: {activeEffect.name}
{effects.map((effect) => ( ))}