a396ee781a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1242 lines
51 KiB
React
1242 lines
51 KiB
React
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { X, AlertCircle, RotateCcw, ChevronDown, ChevronRight, Trash2, GripVertical } from 'lucide-react';
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// CATÁLOGO DE NODOS
|
||
// Cada nodo declara:
|
||
// - kind: 'gen' | 'op' | 'blend'
|
||
// - params: hasta 4 floats · slots del vec4<f32> en u.params[idx]
|
||
// - controls: descriptores de UI (pueden agrupar varios params)
|
||
// - body: snippet WGSL del cuerpo de la función
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
const MAX_NODES = 16;
|
||
const ACCENT = '#5eead4';
|
||
const GEN_COLOR = '#5eead4';
|
||
const OP_COLOR = '#c4b5fd';
|
||
const BLEND_COLOR = '#fbbf24';
|
||
|
||
const NODES = {
|
||
// ─── GENERADORES ───────────────────────────────────────────────
|
||
solid: {
|
||
kind: 'gen', label: 'solid', desc: 'color constante',
|
||
params: [
|
||
{ k: 'r', d: 0.35 }, { k: 'g', d: 0.25 }, { k: 'b', d: 0.55 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'color', keys: ['r', 'g', 'b'], label: 'color' },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
return vec4<f32>(p.x, p.y, p.z, 1.0);`,
|
||
},
|
||
|
||
gradient: {
|
||
kind: 'gen', label: 'gradient', desc: 'gradiente direccional',
|
||
params: [
|
||
{ k: 'angle', d: 0.8 }, { k: 'hue', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 6.2832, step: 0.01 },
|
||
{ kind: 'slider', key: 'hue', label: 'tono', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let dir = vec2<f32>(cos(p.x), sin(p.x));
|
||
let t = dot(uv - 0.5, dir) + 0.5;
|
||
let col = 0.5 + 0.5 * cos(6.28318 * (p.y + vec3<f32>(0.0, 0.33, 0.67) + t));
|
||
return vec4<f32>(col, 1.0);`,
|
||
},
|
||
|
||
plasma: {
|
||
kind: 'gen', label: 'plasma', desc: 'onda trigonométrica',
|
||
params: [
|
||
{ k: 'speed', d: 1 }, { k: 'scale', d: 2 }, { k: '_', d: 0 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'slider', key: 'speed', label: 'velocidad', min: 0, max: 3, step: 0.01 },
|
||
{ kind: 'slider', key: 'scale', label: 'escala', min: 0.5, max: 10, step: 0.1 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let col = 0.5 + 0.5 * cos(u.time * p.x + uv.xyx * p.y + vec3<f32>(0.0, 2.0, 4.0));
|
||
return vec4<f32>(col, 1.0);`,
|
||
},
|
||
|
||
checker: {
|
||
kind: 'gen', label: 'checker', desc: 'tablero rotando',
|
||
params: [
|
||
{ k: 'scale', d: 8 }, { k: 'rot', d: 0.25 }, { k: '_', d: 0 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 30, step: 0.5 },
|
||
{ kind: 'slider', key: 'rot', label: 'rotación', min: -2, max: 2, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let aspect = u.resolution.x / u.resolution.y;
|
||
let q0 = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
|
||
let a = u.time * p.y;
|
||
let rm = mat2x2<f32>(cos(a), -sin(a), sin(a), cos(a));
|
||
let q = rm * q0 * p.x;
|
||
let chk = (floor(q.x) + floor(q.y)) - 2.0 * floor((floor(q.x) + floor(q.y)) * 0.5);
|
||
return vec4<f32>(vec3<f32>(chk), 1.0);`,
|
||
},
|
||
|
||
circle: {
|
||
kind: 'gen', label: 'circle', desc: 'sdf de círculo',
|
||
params: [
|
||
{ k: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'radius', d: 0.35 }, { k: 'soft', d: 0.01 },
|
||
],
|
||
controls: [
|
||
{ kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.8, max: 0.8, step: 0.01 },
|
||
{ kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1, step: 0.01 },
|
||
{ kind: 'slider', key: 'soft', label: 'suavidad', min: 0.001, max: 0.1, step: 0.001 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let aspect = u.resolution.x / u.resolution.y;
|
||
let pos = vec2<f32>((uv.x - 0.5) * aspect - p.x, uv.y - 0.5 - p.y);
|
||
let d = length(pos) - p.z;
|
||
let fill = smoothstep(p.w, -p.w, d);
|
||
return mix(c, vec4<f32>(1.0), fill);`,
|
||
},
|
||
|
||
stripes: {
|
||
kind: 'gen', label: 'stripes', desc: 'rayas animadas',
|
||
params: [
|
||
{ k: 'freq', d: 20 }, { k: 'speed', d: 1 }, { k: 'angle', d: 0.5 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 80, step: 0.5 },
|
||
{ kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, step: 0.05 },
|
||
{ kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 3.1416, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let dir = vec2<f32>(cos(p.z), sin(p.z));
|
||
let x = dot(uv, dir);
|
||
let v = 0.5 + 0.5 * sin(x * p.x + u.time * p.y);
|
||
return vec4<f32>(vec3<f32>(v), 1.0);`,
|
||
},
|
||
|
||
noise: {
|
||
kind: 'gen', label: 'noise', desc: 'hash pseudo-aleatorio',
|
||
params: [
|
||
{ k: 'scale', d: 80 }, { k: 'seed', d: 7 }, { k: 'anim', d: 0 }, { k: '_', d: 0 },
|
||
],
|
||
controls: [
|
||
{ kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 200, step: 1 },
|
||
{ kind: 'slider', key: 'seed', label: 'seed', min: 0, max: 100, step: 1 },
|
||
{ kind: 'slider', key: 'anim', label: 'animar', min: 0, max: 10, step: 0.1 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let q = floor(uv * p.x + p.y + u.time * p.z);
|
||
let h = fract(sin(dot(q, vec2<f32>(12.9898, 78.233))) * 43758.5453);
|
||
return vec4<f32>(vec3<f32>(h), 1.0);`,
|
||
},
|
||
|
||
// ─── OPERADORES (toman c, devuelven c') ────────────────────────
|
||
invert: {
|
||
kind: 'op', label: 'invert', desc: '1 − rgb',
|
||
params: [{ k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [],
|
||
body: () => `
|
||
return vec4<f32>(1.0 - c.rgb, c.a);`,
|
||
},
|
||
|
||
gamma: {
|
||
kind: 'op', label: 'gamma', desc: 'pow(rgb, γ)',
|
||
params: [{ k: 'g', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'slider', key: 'g', label: 'γ', min: 0.1, max: 5, step: 0.01 }],
|
||
body: (i) => `
|
||
let g = max(0.01, u.params[${i}].x);
|
||
return vec4<f32>(pow(max(c.rgb, vec3<f32>(0.0)), vec3<f32>(g)), c.a);`,
|
||
},
|
||
|
||
contrast: {
|
||
kind: 'op', label: 'contrast', desc: '(rgb − 0.5)·k + 0.5',
|
||
params: [{ k: 'k', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'slider', key: 'k', label: 'k', min: 0, max: 3, step: 0.01 }],
|
||
body: (i) => `
|
||
return vec4<f32>((c.rgb - vec3<f32>(0.5)) * u.params[${i}].x + vec3<f32>(0.5), c.a);`,
|
||
},
|
||
|
||
saturate: {
|
||
kind: 'op', label: 'saturate', desc: 'lerp(luma, rgb, s)',
|
||
params: [{ k: 's', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'slider', key: 's', label: 's', min: 0, max: 2, step: 0.01 }],
|
||
body: (i) => `
|
||
let luma = dot(c.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||
return vec4<f32>(mix(vec3<f32>(luma), c.rgb, u.params[${i}].x), c.a);`,
|
||
},
|
||
|
||
hueShift: {
|
||
kind: 'op', label: 'hue shift', desc: 'rotar matiz',
|
||
params: [{ k: 'h', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'slider', key: 'h', label: 'h', min: 0, max: 1, step: 0.01 }],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let k = vec3<f32>(0.57735);
|
||
let ca = cos(p.x * 6.2832);
|
||
let sa = sin(p.x * 6.2832);
|
||
let rot = c.rgb * ca + cross(k, c.rgb) * sa + k * dot(k, c.rgb) * (1.0 - ca);
|
||
return vec4<f32>(rot, c.a);`,
|
||
},
|
||
|
||
tint: {
|
||
kind: 'op', label: 'tint', desc: 'rgb × color',
|
||
params: [{ k: 'r', d: 1 }, { k: 'g', d: 0.8 }, { k: 'b', d: 0.7 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'color', keys: ['r', 'g', 'b'], label: 'tinte' }],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
return vec4<f32>(c.rgb * vec3<f32>(p.x, p.y, p.z) * 2.0, c.a);`,
|
||
},
|
||
|
||
posterize: {
|
||
kind: 'op', label: 'posterize', desc: 'cuantizar a N niveles',
|
||
params: [{ k: 'levels', d: 5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [{ kind: 'slider', key: 'levels', label: 'niveles', min: 2, max: 16, step: 1 }],
|
||
body: (i) => `
|
||
let n = max(2.0, u.params[${i}].x);
|
||
return vec4<f32>(floor(c.rgb * n) / n, c.a);`,
|
||
},
|
||
|
||
vignette: {
|
||
kind: 'op', label: 'vignette', desc: 'oscurecer bordes',
|
||
params: [{ k: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'strength', d: 1 }, { k: 'radius', d: 0.5 }],
|
||
controls: [
|
||
{ kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.5, max: 0.5, step: 0.01 },
|
||
{ kind: 'slider', key: 'strength', label: 'fuerza', min: 0, max: 2, step: 0.01 },
|
||
{ kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1.4, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let d = length((uv - vec2<f32>(0.5)) - vec2<f32>(p.x, p.y));
|
||
let v = 1.0 - smoothstep(p.w, p.w + 0.3, d) * p.z;
|
||
return vec4<f32>(c.rgb * v, c.a);`,
|
||
},
|
||
|
||
ripple: {
|
||
kind: 'op', label: 'ripple', desc: 'ondas radiales',
|
||
params: [{ k: 'freq', d: 30 }, { k: 'amp', d: 0.2 }, { k: 'speed', d: 2 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 100, step: 1 },
|
||
{ kind: 'slider', key: 'amp', label: 'amplitud', min: 0, max: 1, step: 0.01 },
|
||
{ kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, step: 0.05 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
let d = length(uv - vec2<f32>(0.5));
|
||
let w = sin(d * p.x - u.time * p.z) * p.y;
|
||
return vec4<f32>(c.rgb * (1.0 + w), c.a);`,
|
||
},
|
||
|
||
pulse: {
|
||
kind: 'op', label: 'pulse', desc: 'onda temporal',
|
||
params: [{ k: 'freq', d: 2 }, { k: 'amount', d: 0.3 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'slider', key: 'freq', label: 'frecuencia', min: 0, max: 10, step: 0.05 },
|
||
{ kind: 'slider', key: 'amount', label: 'cantidad', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let p = u.params[${i}];
|
||
return vec4<f32>(c.rgb * (1.0 + p.y * sin(u.time * p.x)), c.a);`,
|
||
},
|
||
|
||
// ─── BLENDS (fan-in: toman a + b + amount) ─────────────────────
|
||
blend_mix: {
|
||
kind: 'blend', label: 'mix', desc: 'interpolación · mix(a, b, t)',
|
||
params: [{ k: 't', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 't', label: 't', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
return mix(a, b, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_multiply: {
|
||
kind: 'blend', label: 'multiply', desc: 'a · b',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(a.rgb * b.rgb, a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_screen: {
|
||
kind: 'blend', label: 'screen', desc: '1 − (1−a)(1−b)',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(1.0 - (1.0 - a.rgb) * (1.0 - b.rgb), a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_add: {
|
||
kind: 'blend', label: 'add', desc: 'clamp(a + b)',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(min(a.rgb + b.rgb, vec3<f32>(1.0)), a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_difference: {
|
||
kind: 'blend', label: 'difference', desc: '|a − b|',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(abs(a.rgb - b.rgb), a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_darken: {
|
||
kind: 'blend', label: 'darken', desc: 'min(a, b)',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(min(a.rgb, b.rgb), a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_lighten: {
|
||
kind: 'blend', label: 'lighten', desc: 'max(a, b)',
|
||
params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b' },
|
||
{ kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 },
|
||
],
|
||
body: (i) => `
|
||
let r = vec4<f32>(max(a.rgb, b.rgb), a.a);
|
||
return mix(a, r, u.params[${i}].x);`,
|
||
},
|
||
|
||
blend_mask: {
|
||
kind: 'blend', label: 'mask', desc: 'usa luma(b) como alpha',
|
||
params: [{ k: 'invert', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }],
|
||
controls: [
|
||
{ kind: 'source', label: 'source b (máscara)' },
|
||
{ kind: 'select', key: 'invert', label: 'invertir', options: ['no', 'sí'] },
|
||
],
|
||
body: (i) => `
|
||
let luma = dot(b.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||
let m = select(luma, 1.0 - luma, u.params[${i}].x > 0.5);
|
||
return vec4<f32>(a.rgb * m, a.a);`,
|
||
},
|
||
};
|
||
|
||
const NODES_BY_KIND = {
|
||
gen: Object.entries(NODES).filter(([, v]) => v.kind === 'gen') .map(([k, v]) => ({ name: k, ...v })),
|
||
op: Object.entries(NODES).filter(([, v]) => v.kind === 'op') .map(([k, v]) => ({ name: k, ...v })),
|
||
blend: Object.entries(NODES).filter(([, v]) => v.kind === 'blend').map(([k, v]) => ({ name: k, ...v })),
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Compilador: DAG → WGSL (con soporte para blends / fan-in)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function compileDagToWGSL(pipeline) {
|
||
const safe = pipeline.slice(0, MAX_NODES);
|
||
|
||
const fns = safe.map((step, idx) => {
|
||
const def = NODES[step.name];
|
||
const sig = def.kind === 'blend'
|
||
? '(a: vec4<f32>, b: vec4<f32>, uv: vec2<f32>)'
|
||
: '(c: vec4<f32>, uv: vec2<f32>)';
|
||
return `fn node_${idx}${sig} -> vec4<f32> {${def.body(idx)}
|
||
}`;
|
||
}).join('\n\n');
|
||
|
||
let chain;
|
||
if (safe.length === 0) {
|
||
chain = ' var c = vec4<f32>(0.04, 0.04, 0.06, 1.0);';
|
||
} else {
|
||
const lines = safe.map((step, idx) => {
|
||
const def = NODES[step.name];
|
||
const prev = idx === 0 ? 'vec4<f32>(0.0, 0.0, 0.0, 1.0)' : `out_${idx - 1}`;
|
||
if (def.kind === 'blend') {
|
||
// resolver source por id (robusto ante reordenamientos)
|
||
let srcIdx = -1;
|
||
const srcId = step.meta?.sourceId;
|
||
if (srcId) {
|
||
srcIdx = safe.findIndex(s => s.id === srcId);
|
||
}
|
||
if (srcIdx < 0 || srcIdx >= idx) srcIdx = Math.max(0, idx - 2); // fallback: el de hace dos
|
||
const src = idx === 0 ? prev : `out_${Math.min(srcIdx, idx - 1)}`;
|
||
return ` let out_${idx} = node_${idx}(${prev}, ${src}, uv);`;
|
||
}
|
||
return ` let out_${idx} = node_${idx}(${prev}, uv);`;
|
||
});
|
||
chain = lines.join('\n');
|
||
}
|
||
|
||
const finalExpr = safe.length === 0 ? 'c' : `out_${safe.length - 1}`;
|
||
|
||
return `struct Uniforms {
|
||
time: f32,
|
||
_pad: f32,
|
||
resolution: vec2<f32>,
|
||
params: array<vec4<f32>, ${MAX_NODES}>,
|
||
};
|
||
|
||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||
|
||
@vertex
|
||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||
let p = array<vec2<f32>, 3>(
|
||
vec2<f32>(-1.0, -1.0),
|
||
vec2<f32>( 3.0, -1.0),
|
||
vec2<f32>(-1.0, 3.0)
|
||
);
|
||
return vec4<f32>(p[i], 0.0, 1.0);
|
||
}
|
||
|
||
${fns}
|
||
|
||
@fragment
|
||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||
let uv = pos.xy / u.resolution;
|
||
${chain}
|
||
return ${finalExpr};
|
||
}
|
||
`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Uniform buffer
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
const UNIFORM_FLOATS = 4 + MAX_NODES * 4;
|
||
const UNIFORM_BYTES = UNIFORM_FLOATS * 4;
|
||
|
||
function writeUniforms(device, buffer, time, width, height, pipeline) {
|
||
const data = new Float32Array(UNIFORM_FLOATS);
|
||
data[0] = time;
|
||
data[1] = 0;
|
||
data[2] = width;
|
||
data[3] = height;
|
||
for (let i = 0; i < Math.min(pipeline.length, MAX_NODES); i++) {
|
||
const step = pipeline[i];
|
||
const def = NODES[step.name];
|
||
const offset = 4 + i * 4;
|
||
for (let j = 0; j < 4; j++) {
|
||
const p = def.params[j];
|
||
data[offset + j] = p && p.k !== '_' ? (step.params[p.k] ?? p.d) : (p ? p.d : 0);
|
||
}
|
||
}
|
||
device.queue.writeBuffer(buffer, 0, data);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Hook WebGPU + DAG
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function useWebGPUDag(canvasRef, pipeline, topologyKey) {
|
||
const gpu = useRef({
|
||
device: null, context: null, format: null,
|
||
pipeline: null, bindGroup: null, uniformBuffer: null,
|
||
startTime: 0,
|
||
});
|
||
const pipelineRef = useRef(pipeline);
|
||
useEffect(() => { pipelineRef.current = pipeline; }, [pipeline]);
|
||
|
||
const [status, setStatus] = useState('init');
|
||
const [shaderError, setShaderError] = useState(null);
|
||
const [fps, setFps] = useState(0);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
if (!navigator.gpu) { setStatus('unsupported'); return; }
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (!adapter) { setStatus('unsupported'); return; }
|
||
const device = await adapter.requestDevice();
|
||
if (cancelled) return;
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
canvas.width = 400; canvas.height = 400;
|
||
const context = canvas.getContext('webgpu');
|
||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||
context.configure({ device, format, alphaMode: 'premultiplied' });
|
||
const uniformBuffer = device.createBuffer({
|
||
size: UNIFORM_BYTES,
|
||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||
});
|
||
gpu.current = { ...gpu.current, device, context, format, uniformBuffer, startTime: performance.now() };
|
||
device.lost.then(() => setStatus('error'));
|
||
setStatus('ready');
|
||
} catch (e) {
|
||
console.error(e);
|
||
setShaderError(String(e.message || e));
|
||
setStatus('error');
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [canvasRef]);
|
||
|
||
const compileShader = useCallback(async (wgsl) => {
|
||
const { device, format, uniformBuffer } = gpu.current;
|
||
if (!device) return;
|
||
device.pushErrorScope('validation');
|
||
const module = device.createShaderModule({ code: wgsl });
|
||
const info = await module.getCompilationInfo();
|
||
const errors = info.messages.filter(m => m.type === 'error');
|
||
if (errors.length > 0) {
|
||
setShaderError(errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n'));
|
||
await device.popErrorScope();
|
||
return;
|
||
}
|
||
try {
|
||
const p = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: { module, entryPoint: 'vs' },
|
||
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
|
||
primitive:{ topology: 'triangle-list' },
|
||
});
|
||
const bg = device.createBindGroup({
|
||
layout: p.getBindGroupLayout(0),
|
||
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
|
||
});
|
||
gpu.current.pipeline = p;
|
||
gpu.current.bindGroup = bg;
|
||
setShaderError(null);
|
||
} catch (e) {
|
||
setShaderError(String(e.message || e));
|
||
}
|
||
const err = await device.popErrorScope();
|
||
if (err) setShaderError(err.message);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (status !== 'ready') return;
|
||
const wgsl = compileDagToWGSL(pipelineRef.current);
|
||
compileShader(wgsl);
|
||
}, [topologyKey, status, compileShader]);
|
||
|
||
useEffect(() => {
|
||
if (status !== 'ready') return;
|
||
let running = true;
|
||
let frames = 0;
|
||
let lastFpsSample = performance.now();
|
||
|
||
const loop = () => {
|
||
if (!running) return;
|
||
const { device, context, pipeline: gpuPipe, bindGroup, uniformBuffer, startTime } = gpu.current;
|
||
const canvas = canvasRef.current;
|
||
if (device && context && gpuPipe && bindGroup && canvas) {
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
||
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
||
if (canvas.width !== w || canvas.height !== h) {
|
||
canvas.width = w; canvas.height = h;
|
||
}
|
||
const t = (performance.now() - startTime) / 1000;
|
||
writeUniforms(device, uniformBuffer, t, canvas.width, canvas.height, pipelineRef.current);
|
||
|
||
const encoder = device.createCommandEncoder();
|
||
const pass = encoder.beginRenderPass({
|
||
colorAttachments: [{
|
||
view: context.getCurrentTexture().createView(),
|
||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||
loadOp: 'clear', storeOp: 'store',
|
||
}],
|
||
});
|
||
pass.setPipeline(gpuPipe);
|
||
pass.setBindGroup(0, bindGroup);
|
||
pass.draw(3);
|
||
pass.end();
|
||
device.queue.submit([encoder.finish()]);
|
||
|
||
frames++;
|
||
const now = performance.now();
|
||
if (now - lastFpsSample >= 500) {
|
||
setFps(Math.round((frames * 1000) / (now - lastFpsSample)));
|
||
frames = 0; lastFpsSample = now;
|
||
}
|
||
}
|
||
requestAnimationFrame(loop);
|
||
};
|
||
requestAnimationFrame(loop);
|
||
return () => { running = false; };
|
||
}, [status, canvasRef]);
|
||
|
||
const resetTime = useCallback(() => { gpu.current.startTime = performance.now(); }, []);
|
||
return { status, shaderError, fps, resetTime };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Utilidades
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
let _uid = 0;
|
||
const uid = () => `n${++_uid}_${Date.now().toString(36)}`;
|
||
function defaultParams(name) {
|
||
const d = NODES[name];
|
||
const obj = {};
|
||
for (const p of d.params) if (p.k !== '_') obj[p.k] = p.d;
|
||
return obj;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Controles UI
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function ParamSlider({ ctrl, value, onChange, color }) {
|
||
const display = ctrl.step >= 1 ? Math.round(value) : Number(value).toFixed(ctrl.step < 0.01 ? 3 : 2);
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
|
||
<input
|
||
type="range"
|
||
min={ctrl.min} max={ctrl.max} step={ctrl.step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
className="flex-1"
|
||
style={{ accentColor: color, minWidth: '60px' }}
|
||
/>
|
||
<span className="font-mono text-[10px] text-neutral-500 w-10 text-right tabular-nums shrink-0">{display}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function XYPad({ ctrl, xValue, yValue, onChange, color }) {
|
||
const ref = useRef(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
|
||
const xNorm = (xValue - ctrl.min) / (ctrl.max - ctrl.min);
|
||
const yNorm = (yValue - ctrl.min) / (ctrl.max - ctrl.min);
|
||
|
||
const update = useCallback((clientX, clientY) => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const nx = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||
const ny = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
|
||
const xv = ctrl.min + nx * (ctrl.max - ctrl.min);
|
||
const yv = ctrl.min + ny * (ctrl.max - ctrl.min);
|
||
onChange(xv, yv);
|
||
}, [ctrl.min, ctrl.max, onChange]);
|
||
|
||
useEffect(() => {
|
||
if (!dragging) return;
|
||
const onMove = (e) => update(e.clientX, e.clientY);
|
||
const onUp = () => setDragging(false);
|
||
window.addEventListener('pointermove', onMove);
|
||
window.addEventListener('pointerup', onUp);
|
||
return () => {
|
||
window.removeEventListener('pointermove', onMove);
|
||
window.removeEventListener('pointerup', onUp);
|
||
};
|
||
}, [dragging, update]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-baseline justify-between">
|
||
<span className="font-mono text-[10px] text-neutral-400">{ctrl.label}</span>
|
||
<span className="font-mono text-[9px] text-neutral-500 tabular-nums">
|
||
{xValue.toFixed(2)} , {yValue.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<div
|
||
ref={ref}
|
||
onPointerDown={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setDragging(true);
|
||
update(e.clientX, e.clientY);
|
||
}}
|
||
className="relative rounded cursor-crosshair select-none"
|
||
style={{
|
||
width: '100%',
|
||
height: '70px',
|
||
background: `${color}0a`,
|
||
border: `1px solid ${color}30`,
|
||
}}
|
||
>
|
||
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1, background: `${color}18` }} />
|
||
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 1, background: `${color}18` }} />
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${xNorm * 100}%`,
|
||
top: `${(1 - yNorm) * 100}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
width: 10, height: 10,
|
||
borderRadius: '50%',
|
||
background: color,
|
||
boxShadow: `0 0 10px ${color}aa`,
|
||
pointerEvents: 'none',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ColorPicker({ ctrl, r, g, b, onChange, color }) {
|
||
const toHex = () => {
|
||
const h = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0');
|
||
return `#${h(r)}${h(g)}${h(b)}`;
|
||
};
|
||
const fromHex = (hex) => {
|
||
const rr = parseInt(hex.slice(1, 3), 16) / 255;
|
||
const gg = parseInt(hex.slice(3, 5), 16) / 255;
|
||
const bb = parseInt(hex.slice(5, 7), 16) / 255;
|
||
return [rr, gg, bb];
|
||
};
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
|
||
<label
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
className="flex-1 rounded h-6 cursor-pointer overflow-hidden relative"
|
||
style={{ border: `1px solid ${color}30` }}
|
||
>
|
||
<div style={{ position: 'absolute', inset: 0, background: toHex() }} />
|
||
<input
|
||
type="color"
|
||
value={toHex()}
|
||
onChange={(e) => {
|
||
const [rr, gg, bb] = fromHex(e.target.value);
|
||
onChange(rr, gg, bb);
|
||
}}
|
||
className="opacity-0 w-full h-full cursor-pointer"
|
||
/>
|
||
</label>
|
||
<span className="font-mono text-[9px] text-neutral-500 tabular-nums shrink-0">{toHex()}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SelectControl({ ctrl, value, options, onChange, color }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{ctrl.label}</span>
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
className="flex-1 font-mono text-[10px] rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||
style={{
|
||
background: 'rgba(0,0,0,0.4)',
|
||
border: `1px solid ${color}40`,
|
||
color,
|
||
}}
|
||
>
|
||
{options.map((opt, i) => (
|
||
<option key={i} value={i} style={{ background: '#0a0a0a', color: '#d4d4d4' }}>{opt}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Paleta item
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function PaletteItem({ node, color, onClick }) {
|
||
return (
|
||
<div
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData('text/node-name', node.name);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
}}
|
||
onClick={onClick}
|
||
className="group flex items-center gap-2 px-2 py-1 rounded cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60"
|
||
style={{ borderLeft: `2px solid ${color}` }}
|
||
title={node.desc}
|
||
>
|
||
<span className="font-mono text-[11px] font-medium flex-1" style={{ color }}>{node.label}</span>
|
||
<span className="font-mono text-[8px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity uppercase tracking-wider">drag</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Nodo del pipeline (con drag handle aislado)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function PipelineNode({ step, index, pipeline, onRemove, onChangeParams, onChangeMeta, onDragStart, onDrop, isDragging }) {
|
||
const [armed, setArmed] = useState(false); // drag solo se arma desde el handle
|
||
const def = NODES[step.name];
|
||
const color = def.kind === 'gen' ? GEN_COLOR : def.kind === 'op' ? OP_COLOR : BLEND_COLOR;
|
||
|
||
const previousOptions = pipeline.slice(0, index).map((s, i) => `${i}: ${NODES[s.name].label}`);
|
||
const currentSourceIdx = (() => {
|
||
const srcId = step.meta?.sourceId;
|
||
const idx = srcId ? pipeline.findIndex(s => s.id === srcId) : -1;
|
||
return idx >= 0 && idx < index ? idx : Math.max(0, index - 2);
|
||
})();
|
||
|
||
const setSource = (optionIdx) => {
|
||
const srcStep = pipeline[optionIdx];
|
||
if (srcStep) onChangeMeta(index, { sourceId: srcStep.id });
|
||
};
|
||
|
||
const renderControl = (ctrl, i) => {
|
||
if (ctrl.kind === 'slider') {
|
||
return (
|
||
<ParamSlider
|
||
key={i} ctrl={ctrl} color={color}
|
||
value={step.params[ctrl.key] ?? def.params.find(p => p.k === ctrl.key)?.d ?? 0}
|
||
onChange={(v) => onChangeParams(index, { [ctrl.key]: v })}
|
||
/>
|
||
);
|
||
}
|
||
if (ctrl.kind === 'xy') {
|
||
const [xk, yk] = ctrl.keys;
|
||
return (
|
||
<XYPad
|
||
key={i} ctrl={ctrl} color={color}
|
||
xValue={step.params[xk] ?? 0} yValue={step.params[yk] ?? 0}
|
||
onChange={(xv, yv) => onChangeParams(index, { [xk]: xv, [yk]: yv })}
|
||
/>
|
||
);
|
||
}
|
||
if (ctrl.kind === 'color') {
|
||
const [rk, gk, bk] = ctrl.keys;
|
||
return (
|
||
<ColorPicker
|
||
key={i} ctrl={ctrl} color={color}
|
||
r={step.params[rk] ?? 0} g={step.params[gk] ?? 0} b={step.params[bk] ?? 0}
|
||
onChange={(r, g, b) => onChangeParams(index, { [rk]: r, [gk]: g, [bk]: b })}
|
||
/>
|
||
);
|
||
}
|
||
if (ctrl.kind === 'select') {
|
||
return (
|
||
<SelectControl
|
||
key={i} ctrl={ctrl} color={color}
|
||
value={step.params[ctrl.key] ?? 0}
|
||
options={ctrl.options}
|
||
onChange={(v) => onChangeParams(index, { [ctrl.key]: v })}
|
||
/>
|
||
);
|
||
}
|
||
if (ctrl.kind === 'source') {
|
||
if (previousOptions.length === 0) {
|
||
return (
|
||
<div key={i} className="font-mono text-[9px] text-rose-400/70 italic">
|
||
· requiere al menos un nodo antes ·
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<SelectControl
|
||
key={i}
|
||
ctrl={{ label: ctrl.label }}
|
||
value={currentSourceIdx}
|
||
options={previousOptions}
|
||
onChange={setSource}
|
||
color={color}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<div
|
||
draggable={armed}
|
||
onDragStart={(e) => {
|
||
if (!armed) { e.preventDefault(); return; }
|
||
e.dataTransfer.setData('text/reorder-index', String(index));
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
onDragStart?.(index);
|
||
}}
|
||
onDragEnd={() => setArmed(false)}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => onDrop?.(e, index)}
|
||
className="rounded-lg transition-all"
|
||
style={{
|
||
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
|
||
border: `1px solid ${color}30`,
|
||
opacity: isDragging ? 0.4 : 1,
|
||
}}
|
||
>
|
||
{/* ── HEADER · única zona que arma el drag ── */}
|
||
<div
|
||
className="flex items-center gap-2 px-2.5 py-1.5 border-b cursor-grab active:cursor-grabbing select-none"
|
||
style={{ borderColor: `${color}20` }}
|
||
onPointerDown={() => setArmed(true)}
|
||
onPointerUp={() => setArmed(false)}
|
||
onPointerLeave={() => setArmed(false)}
|
||
>
|
||
<GripVertical size={11} className="text-neutral-600 shrink-0" />
|
||
<span className="font-mono text-[9px] uppercase tracking-wider text-neutral-500 shrink-0">
|
||
{def.kind} · {index}
|
||
</span>
|
||
<span className="font-mono text-xs font-semibold flex-1 truncate" style={{ color }}>{def.label}</span>
|
||
<button
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onClick={() => onRemove(index)}
|
||
className="text-neutral-500 hover:text-neutral-200 p-0.5"
|
||
aria-label="Eliminar"
|
||
>
|
||
<X size={11} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── CONTROLES ── */}
|
||
{def.controls && def.controls.length > 0 ? (
|
||
<div className="px-2.5 py-2 flex flex-col gap-2">
|
||
{def.controls.map((c, i) => renderControl(c, i))}
|
||
</div>
|
||
) : (
|
||
<div className="px-2.5 py-1.5 font-mono text-[9px] text-neutral-600 italic">sin parámetros</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Badges y overlays
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
function StatusBadge({ status }) {
|
||
const map = {
|
||
init: { color: '#fbbf24', label: 'inicializando' },
|
||
ready: { color: ACCENT, label: 'activo' },
|
||
unsupported: { color: '#f43f5e', label: 'sin webgpu' },
|
||
error: { color: '#f43f5e', label: 'error' },
|
||
};
|
||
const s = map[status] || map.init;
|
||
return (
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: s.color, boxShadow: `0 0 8px ${s.color}` }} />
|
||
<span className="font-mono text-[10px]" style={{ color: s.color }}>{s.label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StatusOverlay({ status, error }) {
|
||
if (status === 'ready') return null;
|
||
return (
|
||
<div className="absolute inset-0 flex items-center justify-center p-6 text-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
|
||
{status === 'init' && <div className="font-mono text-[11px] text-neutral-400">inicializando adaptador GPU…</div>}
|
||
{status === 'unsupported' && (
|
||
<div className="max-w-sm">
|
||
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>WebGPU no disponible</div>
|
||
<div className="font-mono text-[10px] text-neutral-400 leading-relaxed">
|
||
chrome/edge 113+, safari 18+, o firefox nightly con flag. prueba abrir en pestaña nueva si estás en un iframe.
|
||
</div>
|
||
</div>
|
||
)}
|
||
{status === 'error' && (
|
||
<div className="max-w-sm">
|
||
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>error</div>
|
||
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// APP
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
export default function App() {
|
||
const canvasRef = useRef(null);
|
||
const [pipeline, setPipeline] = useState(() => {
|
||
const n1 = { id: uid(), name: 'plasma', params: defaultParams('plasma'), meta: {} };
|
||
const n2 = { id: uid(), name: 'circle', params: defaultParams('circle'), meta: {} };
|
||
const n3 = { id: uid(), name: 'blend_multiply', params: defaultParams('blend_multiply'), meta: { sourceId: n1.id } };
|
||
const n4 = { id: uid(), name: 'vignette', params: defaultParams('vignette'), meta: {} };
|
||
return [n1, n2, n3, n4];
|
||
});
|
||
const [dragIndex, setDragIndex] = useState(null);
|
||
const [wgslOpen, setWgslOpen] = useState(false);
|
||
|
||
// topologyKey captura nombre + meta relevantes a la generación de código
|
||
const topologyKey = useMemo(
|
||
() => pipeline.map(s => `${s.name}:${s.meta?.sourceId ?? ''}`).join('|'),
|
||
[pipeline]
|
||
);
|
||
|
||
const { status, shaderError, fps, resetTime } = useWebGPUDag(canvasRef, pipeline, topologyKey);
|
||
const generatedWGSL = useMemo(() => compileDagToWGSL(pipeline), [topologyKey]);
|
||
|
||
const addNode = (name) => {
|
||
if (pipeline.length >= MAX_NODES) return;
|
||
setPipeline(p => {
|
||
const newNode = { id: uid(), name, params: defaultParams(name), meta: {} };
|
||
// si es un blend, elegir por defecto el nodo de hace dos como fuente (o el primero si no hay)
|
||
if (NODES[name].kind === 'blend' && p.length >= 1) {
|
||
const fallbackSrc = p[Math.max(0, p.length - 2)];
|
||
if (fallbackSrc) newNode.meta.sourceId = fallbackSrc.id;
|
||
}
|
||
return [...p, newNode];
|
||
});
|
||
};
|
||
const removeNode = (idx) => setPipeline(p => p.filter((_, i) => i !== idx));
|
||
const changeParams = (idx, updates) => {
|
||
setPipeline(p => p.map((s, i) => i === idx ? { ...s, params: { ...s.params, ...updates } } : s));
|
||
};
|
||
const changeMeta = (idx, updates) => {
|
||
setPipeline(p => p.map((s, i) => i === idx ? { ...s, meta: { ...(s.meta || {}), ...updates } } : s));
|
||
};
|
||
|
||
const handleDropOnZone = (e) => {
|
||
e.preventDefault();
|
||
const nodeName = e.dataTransfer.getData('text/node-name');
|
||
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
|
||
if (nodeName && NODES[nodeName]) {
|
||
addNode(nodeName);
|
||
} else if (reorderIdx !== '') {
|
||
const from = Number(reorderIdx);
|
||
setPipeline(p => {
|
||
const copy = [...p];
|
||
const [m] = copy.splice(from, 1);
|
||
copy.push(m);
|
||
return copy;
|
||
});
|
||
}
|
||
setDragIndex(null);
|
||
};
|
||
|
||
const handleDropOnNode = (e, targetIdx) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const nodeName = e.dataTransfer.getData('text/node-name');
|
||
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
|
||
if (nodeName && NODES[nodeName]) {
|
||
setPipeline(p => {
|
||
const copy = [...p];
|
||
const newNode = { id: uid(), name: nodeName, params: defaultParams(nodeName), meta: {} };
|
||
if (NODES[nodeName].kind === 'blend' && targetIdx >= 1) {
|
||
const fallbackSrc = copy[Math.max(0, targetIdx - 1)];
|
||
if (fallbackSrc) newNode.meta.sourceId = fallbackSrc.id;
|
||
}
|
||
copy.splice(targetIdx, 0, newNode);
|
||
return copy;
|
||
});
|
||
} else if (reorderIdx !== '') {
|
||
const from = Number(reorderIdx);
|
||
if (from === targetIdx) return;
|
||
setPipeline(p => {
|
||
const copy = [...p];
|
||
const [m] = copy.splice(from, 1);
|
||
const adj = from < targetIdx ? targetIdx - 1 : targetIdx;
|
||
copy.splice(adj, 0, m);
|
||
return copy;
|
||
});
|
||
}
|
||
setDragIndex(null);
|
||
};
|
||
|
||
return (
|
||
<div className="w-full min-h-screen text-neutral-200" style={{
|
||
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
|
||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||
}}>
|
||
<style>{`
|
||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
|
||
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||
.grid-bg {
|
||
background-image:
|
||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
}
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
|
||
input[type="range"] { height: 4px; }
|
||
`}</style>
|
||
|
||
<div className="grid-bg min-h-screen">
|
||
{/* HEADER */}
|
||
<header className="px-6 pt-6 pb-5 border-b border-white/5">
|
||
<div className="flex items-end justify-between flex-wrap gap-4">
|
||
<div>
|
||
<div className="flex items-baseline gap-3">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 004</span>
|
||
<span className="font-mono text-[10px] text-neutral-700">/</span>
|
||
<span className="font-mono text-[10px] text-neutral-500">shader dag · fan-in</span>
|
||
<span className="font-mono text-[10px] text-neutral-700">/</span>
|
||
<StatusBadge status={status} />
|
||
</div>
|
||
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
|
||
composición con <em className="italic" style={{ color: BLEND_COLOR }}>blends</em>
|
||
</h1>
|
||
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
|
||
gen · op · blend · xy pads, colores y selectores · drag desde el handle ⋮⋮
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="font-mono text-[10px] text-neutral-600">{fps} fps</span>
|
||
<button
|
||
onClick={resetTime}
|
||
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
|
||
>
|
||
<RotateCcw size={11} /> reset t
|
||
</button>
|
||
<button
|
||
onClick={() => setPipeline([])}
|
||
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
|
||
>
|
||
<Trash2 size={11} /> vaciar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* MAIN · 3 cols */}
|
||
<main style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '180px minmax(300px, 1fr) minmax(320px, 420px)',
|
||
minHeight: 'calc(100vh - 110px)',
|
||
overflowX: 'auto',
|
||
}}>
|
||
{/* PALETA */}
|
||
<aside className="border-r border-white/5 p-4 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 110px)' }}>
|
||
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
|
||
|
||
{['gen', 'op', 'blend'].map(kind => {
|
||
const meta = {
|
||
gen: { color: GEN_COLOR, label: 'generadores', sub: 'producen color · ignoran c' },
|
||
op: { color: OP_COLOR, label: 'operadores', sub: 'transforman c' },
|
||
blend: { color: BLEND_COLOR, label: 'blends', sub: 'fan-in: a + b' },
|
||
}[kind];
|
||
return (
|
||
<section key={kind} className="mb-5 last:mb-0">
|
||
<div className="flex items-center gap-2 mb-1 px-1">
|
||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: meta.color, boxShadow: `0 0 8px ${meta.color}` }} />
|
||
<span className="font-display text-sm italic" style={{ color: meta.color }}>{meta.label}</span>
|
||
</div>
|
||
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">{meta.sub}</div>
|
||
<div className="flex flex-col gap-0.5">
|
||
{NODES_BY_KIND[kind].map(n => (
|
||
<PaletteItem key={n.name} node={n} color={meta.color} onClick={() => addNode(n.name)} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
|
||
<div className="mt-6 pt-4 border-t border-white/5 font-mono text-[9px] text-neutral-600 leading-relaxed">
|
||
drag desde <span style={{color: ACCENT}}>⋮⋮ header</span> para reordenar · sliders y pads son independientes
|
||
</div>
|
||
</aside>
|
||
|
||
{/* PIPELINE */}
|
||
<section className="p-4 overflow-y-auto border-r border-white/5" style={{ maxHeight: 'calc(100vh - 110px)' }}>
|
||
<div className="flex items-baseline gap-3 mb-3">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">pipeline</span>
|
||
<span className="font-mono text-[10px] text-neutral-600">{pipeline.length}/{MAX_NODES} nodos</span>
|
||
</div>
|
||
|
||
<div
|
||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
|
||
onDrop={handleDropOnZone}
|
||
className="rounded-xl border border-dashed border-white/10 p-3 transition-colors hover:border-white/20"
|
||
style={{ background: 'rgba(255,255,255,0.015)', minHeight: '300px' }}
|
||
>
|
||
{pipeline.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||
<div className="font-display text-lg italic text-neutral-500 mb-1">pipeline vacío</div>
|
||
<div className="font-mono text-[10px] text-neutral-600">arrastra una primitiva de la izquierda</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{pipeline.map((step, i) => (
|
||
<PipelineNode
|
||
key={step.id}
|
||
step={step}
|
||
index={i}
|
||
pipeline={pipeline}
|
||
onRemove={removeNode}
|
||
onChangeParams={changeParams}
|
||
onChangeMeta={changeMeta}
|
||
onDragStart={setDragIndex}
|
||
onDrop={handleDropOnNode}
|
||
isDragging={dragIndex === i}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
|
||
<span style={{ color: GEN_COLOR }}>gen</span> produce · <span style={{ color: OP_COLOR }}>op</span> transforma · <span style={{ color: BLEND_COLOR }}>blend</span> combina dos nodos (fan-in real)
|
||
</div>
|
||
</section>
|
||
|
||
{/* CANVAS + WGSL */}
|
||
<aside className="p-4 overflow-y-auto flex flex-col gap-3" style={{ maxHeight: 'calc(100vh - 110px)' }}>
|
||
<div className="flex items-baseline gap-3">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">output</span>
|
||
<span className="font-mono text-[9px] text-neutral-600">fullscreen triangle</span>
|
||
</div>
|
||
<div
|
||
className="rounded-xl border border-white/10 overflow-hidden relative"
|
||
style={{
|
||
aspectRatio: '1/1',
|
||
background: '#000',
|
||
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
|
||
}}
|
||
>
|
||
<canvas ref={canvasRef} className="block" style={{ width: '100%', height: '100%' }} />
|
||
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
|
||
</div>
|
||
|
||
{shaderError && status === 'ready' && (
|
||
<div className="rounded-lg p-3" style={{ background: '#f43f5e0a', border: '1px solid #f43f5e30' }}>
|
||
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-rose-400 mb-1.5 flex items-center gap-1.5">
|
||
<AlertCircle size={11} /> compilación fallida
|
||
</div>
|
||
<pre className="font-mono text-[10px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
|
||
<div className="font-mono text-[9px] text-neutral-500 mt-2">último pipeline válido sigue activo</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-lg border border-white/5" style={{ background: 'rgba(255,255,255,0.015)' }}>
|
||
<button
|
||
onClick={() => setWgslOpen(o => !o)}
|
||
className="w-full flex items-center gap-2 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 hover:text-neutral-300"
|
||
>
|
||
{wgslOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
||
wgsl generado
|
||
<span className="ml-auto text-neutral-600 normal-case tracking-normal">{generatedWGSL.split('\n').length} líneas</span>
|
||
</button>
|
||
{wgslOpen && (
|
||
<pre className="font-mono text-[10px] text-neutral-400 px-3 pb-3 overflow-auto leading-relaxed" style={{ maxHeight: '260px' }}>
|
||
{generatedWGSL}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
|
||
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
|
||
blends leen dos variables · <span style={{ color: BLEND_COLOR }}>out_<idx-1></span> y <span style={{ color: BLEND_COLOR }}>out_<source></span> · el DAG ya no es lineal
|
||
</div>
|
||
</aside>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|