diff --git a/.changeset/alpha-compositing-foundation.md b/.changeset/alpha-compositing-foundation.md new file mode 100644 index 00000000..cd35f4c5 --- /dev/null +++ b/.changeset/alpha-compositing-foundation.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Preserve alpha when copying external input textures and applying effect passes. Source layers use straight-alpha source-over blending; effect passes preserve input coverage and retain their existing alpha-as-strength behavior. Opaque compositions and legacy mask semantics are unchanged. diff --git a/.changeset/annotations-layer.md b/.changeset/annotations-layer.md new file mode 100644 index 00000000..2224df0a --- /dev/null +++ b/.changeset/annotations-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add an Annotations effect layer: seeded, edge-guided or painted placement of decorative technical marks (target rings, dashed circles and boxes, crosshairs, rulers, connectors, readouts, metadata blocks) with presets, editable text, monochrome or palette colors, rendered identically in the editor and runtime. diff --git a/.changeset/annotations-regions-rotation.md b/.changeset/annotations-regions-rotation.md new file mode 100644 index 00000000..18b0da2b --- /dev/null +++ b/.changeset/annotations-regions-rotation.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Annotations can place marks by image regions, draw connected dots and turn individual elements. Placement → Regions groups the image below into areas of similar tone and color from the same low-resolution field Edges already reads, then frames regions with boxes, traces their outlines with dots and tags them with labels; region ids persist across video frames so marks follow the image instead of flickering. Connected Dots adds small constellations of dots linked to their nearest neighbours. Rotation Jitter turns rings, crosses and boxes by a seeded angle, and Align to Edges turns them along the image edges or each region's long axis. All new controls default to off, so existing annotations render exactly as before. diff --git a/.changeset/blob-inner-effects.md b/.changeset/blob-inner-effects.md new file mode 100644 index 00000000..3e35b604 --- /dev/null +++ b/.changeset/blob-inner-effects.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Blob Tracking inner effects cover every current effect, including Blur, Glass, Gradient Map, Grain, Lumen Print, Photocopy, Relief, Erosion and Signal Rot. A newly picked inner effect now starts from its default settings instead of rendering nothing until a setting was changed. diff --git a/.changeset/blob-tracking-annotations.md b/.changeset/blob-tracking-annotations.md new file mode 100644 index 00000000..63959164 --- /dev/null +++ b/.changeset/blob-tracking-annotations.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Blob Tracking gains corner-bracket frames, edge dots along each blob's silhouette, and decorative label modes (prefix + seeded ID, custom list). Existing scenes keep their outline and coordinate labels. diff --git a/.changeset/brush-mask-feather.md b/.changeset/brush-mask-feather.md new file mode 100644 index 00000000..a7e8b314 --- /dev/null +++ b/.changeset/brush-mask-feather.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Brush masks gain Feather: painted mask edges soften by a keyframable distance measured from the painted edge, the same way ellipse and rectangle masks soften. Feather 0 keeps a hard painted edge; the existing default of 0.01 softens old brush masks very slightly. diff --git a/.changeset/cell-edge-scatter.md b/.changeset/cell-edge-scatter.md new file mode 100644 index 00000000..9035a4b6 --- /dev/null +++ b/.changeset/cell-edge-scatter.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add optional, seeded Edge Scatter to Photographic Cells regions and painted reveals. Scatter fragments boundaries without moving source detail or adding texture samples. Missing values and zero preserve existing projects. diff --git a/.changeset/connected-dots-edge-snap.md b/.changeset/connected-dots-edge-snap.md new file mode 100644 index 00000000..451ad507 --- /dev/null +++ b/.changeset/connected-dots-edge-snap.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Connected Dots gains Edge Snap, which pulls each point onto the strongest image edge inside its cell so dots, links and mesh triangles trace contours, and two styles that use it: Contour Graph and Facets. At 0 (the default, and for every existing style and saved project) the output is unchanged. Mesh mode now reads its points from the per-cell pre-pass, about twice as fast at 1080p with identical pixels. diff --git a/.changeset/connected-dots-layer.md b/.changeset/connected-dots-layer.md new file mode 100644 index 00000000..8de57bea --- /dev/null +++ b/.changeset/connected-dots-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Connected Dots effect layer: the image becomes jittered points linked to their neighbors. Graph links dots by tone with links that thicken in the darks; Blobs melts dots and links into organic masses; Plexus draws thin lines that fade with distance. Circle, square, plus or ring dots sized by tone, a tone cutoff that drops the background, palette-by-tone (flat bands from an editable ramp of up to 8 stops), source or ink color, a color, image or transparent background, and Drift so links form and break over time. Four starting styles (Portrait Graph, Ink Blobs, Plexus, Constellation). diff --git a/.changeset/connected-dots-mesh.md b/.changeset/connected-dots-mesh.md new file mode 100644 index 00000000..9b0382d4 --- /dev/null +++ b/.changeset/connected-dots-mesh.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Connected Dots gains a Mesh mode (flat-colored triangles between the points, with Fill, Wire and Wire Color) and six new styles: Molecule, Circuit, Neural, Riso, Low Poly and Wireframe. diff --git a/.changeset/depth-parallax.md b/.changeset/depth-parallax.md new file mode 100644 index 00000000..88a4154d --- /dev/null +++ b/.changeset/depth-parallax.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Image layers accept a grayscale depth map as a second asset (`depthAsset` in the runtime config) and gain Depth controls: invert, range, focus plane, orbit/sway/nod/dolly camera motion with amount and speed, a keyframable camera offset, stretched or transparent edges, march quality and a depth preview. The pass marches depth layers from near to far with binary refinement so nearer content covers what is behind it. Layers without a depth map keep the previous shader. An **Estimate** button generates the depth map in the browser with Depth Anything V2 Small (transformers.js in a Web Worker, WebGPU with a WASM fallback) and attaches it as a local asset. The image pass publishes its displaced depth as scene depth for the layers above: ASCII, Halftone, Pattern and Gradient Map gain `input: "luminance" | "depth"`, and layer masks gain a `depth` shape with a near/far range. Mask center, size, rotation and feather are keyframable and audio-linkable as `mask.*` tracks, evaluated in the editor and the runtime. diff --git a/.changeset/displaced-rings.md b/.changeset/displaced-rings.md new file mode 100644 index 00000000..e71cfddf --- /dev/null +++ b/.changeset/displaced-rings.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Displaced Rings with rings/half-discs, progressive transforms, seeded offsets, and Distort/Cutout output. Support transformed coverage in the editor and runtime so grouped photographic cutouts reveal external layers, with opacity interpolating color and alpha together. diff --git a/.changeset/dot-grid-layer.md b/.changeset/dot-grid-layer.md new file mode 100644 index 00000000..17213570 --- /dev/null +++ b/.changeset/dot-grid-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Dot Grid effect layer: an exact, unrotated dot grid fixed to the artboard where every cell gets a dot that grows with the tone, with Level, Contrast, Softness, circle or square dots, single-color or source ink, a background and a blurred underlay. Four starting styles (Coordinate, Source Dots, Night, Pixel Grid). It renders identically in the editor, exports and the runtime package. Halftone is unchanged. diff --git a/.changeset/editor-groups.md b/.changeset/editor-groups.md new file mode 100644 index 00000000..335e783a --- /dev/null +++ b/.changeset/editor-groups.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Support isolated groups in exported ShaderLabConfig layer lists using group layers and parentId references. Evaluate group visibility and opacity before building the render tree, preserving child effect scope in composition and headless rendering. Existing flat configs remain compatible. diff --git a/.changeset/erosion-layer.md b/.changeset/erosion-layer.md new file mode 100644 index 00000000..7d421b77 --- /dev/null +++ b/.changeset/erosion-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add an Erosion effect layer that crumbles the image into speckle along its edges, light or dark tones, or the border of a cutout, with Erode, Edge Width, Speckle Size, Clumping and Scatter (fragments thrown across the edge), revealing paper or transparent holes, frozen or animated. Three starting styles (Disintegrate, Paper Erosion, Crumbled Cutout). It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/flares-layer.md b/.changeset/flares-layer.md new file mode 100644 index 00000000..b13ad86b --- /dev/null +++ b/.changeset/flares-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Flares effect layer: light flares from the brightest small points in the image, with Threshold, Isolation (large bright areas stay clean), Intensity, 1 to 16 rays, rotation, length, secondary ray length, length jitter, thickness, falloff, ray and core colors and a core glow. Five starting styles (Cross, Star, Starburst, Asterisk, Anamorphic). Flares extend over transparent areas. It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/focus-blur-layer.md b/.changeset/focus-blur-layer.md new file mode 100644 index 00000000..005451a2 --- /dev/null +++ b/.changeset/focus-blur-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Blur effect layer built on a seven-level blur pyramid with B-spline sampling, so any radius stays smooth and the blur size can change per pixel. Blur From: Uniform, Depth (depth of field from the scene depth map), Linear and Radial focus zones, or Luminance, with Focus, Focus Range, Transition, Center, Band Angle and Invert Focus. Kinds: Gaussian, Lens bokeh with Highlights, and Motion. Grain keeps large blurs from banding. Five starting styles (Depth of Field, Tilt-Shift, Vignette, Soft, Motion). Directional Blur, Progressive Blur and Fluted Glass leave the add-layer menu; saved projects keep rendering them. diff --git a/.changeset/glass-layer.md b/.changeset/glass-layer.md new file mode 100644 index 00000000..c16b44a5 --- /dev/null +++ b/.changeset/glass-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Glass effect layer that replaces Fluted Glass: reeded (round or sharp flutes), hammered, pyramid, hex or frosted glass where every cell is a small lens, the scene blurs with its distance behind the glass (uniform or from the depth map, through the shared blur pyramid), and thin rims, grooves, frost, dispersion and tint make it read as glass. Six starting styles (Reeded, Frosted Reeded, Hammered, Pyramid, Hex, Frosted). The Blur layer now shares the same blur pyramid module. diff --git a/.changeset/gradient-map-keyframes.md b/.changeset/gradient-map-keyframes.md new file mode 100644 index 00000000..4e97924b --- /dev/null +++ b/.changeset/gradient-map-keyframes.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Gradient Map ramps can be animated. Timeline tracks with the new `gradient` value type blend between two ramps: stops glide when both ramps have the same number of stops, and when the counts differ the shorter ramp gains stops that leave its look unchanged, so the in-between frames stay a smooth crossfade. Each keyframe keeps its easing. Scenes without ramp tracks render exactly as before. diff --git a/.changeset/gradient-map-layer.md b/.changeset/gradient-map-layer.md new file mode 100644 index 00000000..0bd3807f --- /dev/null +++ b/.changeset/gradient-map-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Gradient Map effect layer that recolors by tone with an editable ramp, presets, Amount and Invert. It renders identically in the editor, exports and the runtime package, and can be scoped with groups and masks. The global Color Map is unchanged. diff --git a/.changeset/grain-layer.md b/.changeset/grain-layer.md new file mode 100644 index 00000000..40ad4b68 --- /dev/null +++ b/.changeset/grain-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Grain effect layer: photographic film grain for photos and video, with Amount, Size in document pixels, Roughness, Clumping, Chroma that leaves brightness unchanged, a tonal Response that favors the midtones like film, and Soft Light, Overlay or Add blending. Speed 0 holds one pattern; above 0 it steps to a new pattern 24 times a second like film. Five starting styles (Fine 35mm, Color Negative, 16mm, Pushed, Digital Noise). It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/group-drag-drop.md b/.changeset/group-drag-drop.md new file mode 100644 index 00000000..36de81c8 --- /dev/null +++ b/.changeset/group-drag-drop.md @@ -0,0 +1,4 @@ +--- +--- + +Fix editor layer dragging so dropping into or out of a group updates membership and order together. Add destination indicators, subtree movement, cancellation, and edge scrolling. This editor-only change does not require a package release. diff --git a/.changeset/isolated-group-rendering.md b/.changeset/isolated-group-rendering.md new file mode 100644 index 00000000..e4187797 --- /dev/null +++ b/.changeset/isolated-group-rendering.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Add isolated, nested group rendering to the internal renderer frame contract. Group children render against transparent targets before the combined result is blended into the parent. Existing flat public shader configs retain their behavior; editor grouping controls and persisted groups follow separately. diff --git a/.changeset/layer-masks.md b/.changeset/layer-masks.md new file mode 100644 index 00000000..06ba3d40 --- /dev/null +++ b/.changeset/layer-masks.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add reusable layer masks: linear and radial gradients, ellipse, rectangle and brush on any layer or group. Masks either limit an effect or cut coverage, support invert and feather (measured from the true shape edge, so stretched ellipses soften evenly), and render identically in the editor, PNG/video export and the runtime package. diff --git a/.changeset/lumen-print-layer.md b/.changeset/lumen-print-layer.md new file mode 100644 index 00000000..685807d5 --- /dev/null +++ b/.changeset/lumen-print-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Lumen Print effect layer: an analog photographic print with sun-print toning through an editable palette, solarization with edge lines, diffusion and halation, highlights washed into paper, burned borders and grain. Six starting styles (Lumen, Cyanotype, Burned, Sabattier, Washed, Eroded). It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/mcp-layer-masks.md b/.changeset/mcp-layer-masks.md new file mode 100644 index 00000000..26831036 --- /dev/null +++ b/.changeset/mcp-layer-masks.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab-mcp": minor +--- + +`update_layer` accepts a `mask`: linear, radial, ellipse, rectangle or brush shapes in centered shorter-edge composition units, scoped to the effect or the content, with invert, rotation and feather. Pass `null` to reset it. diff --git a/.changeset/mesh-gradient-v2.md b/.changeset/mesh-gradient-v2.md new file mode 100644 index 00000000..1f702694 --- /dev/null +++ b/.changeset/mesh-gradient-v2.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Mesh Gradient gets eight new smooth presets (Peach, Lagoon, Dusk, Citrus, Mint, Aurora, Ink, Basement), a Simple distortion mode with Softness, Warp and Detail, and motion styles (Drift, Breathe, Swirl, Flow) that loop seamlessly over the timeline duration. Passes receive the timeline duration through a new `updateTimelineDuration` hook. Saved gradients keep their exact look: they load in Advanced mode with their Original motion. diff --git a/.changeset/outline-layer.md b/.changeset/outline-layer.md new file mode 100644 index 00000000..bf6230a2 --- /dev/null +++ b/.changeset/outline-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add an Outline effect layer on a jump-flood distance field: solid, double, dashed or scalloped cloud outlines around text, cutouts or dark and light shapes, with offset (negative draws inside), width, repeated rings, fill between shape and outline, and a line-only mode. Six starting styles (Stroke, Cloud, Sticker, Contour Rings, Cut Line, Double). diff --git a/.changeset/pattern-motif-palette.md b/.changeset/pattern-motif-palette.md new file mode 100644 index 00000000..12addb5c --- /dev/null +++ b/.changeset/pattern-motif-palette.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Pattern motifs can recolor their SVG files. With Color Mode set to Motif colors, the editor lists every color found in the SVG motifs under File colors, and each one can be replaced, the same way SVG shapes work. Image motifs and projects without a palette render exactly as before. diff --git a/.changeset/pattern-motifs.md b/.changeset/pattern-motifs.md new file mode 100644 index 00000000..1840d741 --- /dev/null +++ b/.changeset/pattern-motifs.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Pattern layers can use your own motifs: set `preset: "custom"` and list up to ten images or SVGs in `patternAssets`, ordered from the lightest tones to the darkest. Each motif takes an equal band of tones, and Invert reverses the order. The new `colorMode: "original"` keeps each motif's own colors; the other color modes use the motif's alpha as its shape. diff --git a/.changeset/photocopy-layer.md b/.changeset/photocopy-layer.md new file mode 100644 index 00000000..a020d4e4 --- /dev/null +++ b/.changeset/photocopy-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Photocopy effect layer: crushed toner over paper with Threshold, Contrast and Generations (a copy of a copy), uneven fill, toner speckle, drum streaks, misregistration, grain and fold creases, any toner and paper color or transparent paper. Five starting styles (Office Copy, Zine, Fax, Blueprint Copy, Pink Flyer). diff --git a/.changeset/photographic-cell-paint.md b/.changeset/photographic-cell-paint.md new file mode 100644 index 00000000..a6117133 --- /dev/null +++ b/.changeset/photographic-cell-paint.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add persisted Paint selection to Photographic Cells. Painted coverage shares cell geometry and perimeter outlines with automatic regions, stays anchored in composition space, and reuses its GPU texture while the source animates. diff --git a/.changeset/photographic-cell-regions.md b/.changeset/photographic-cell-regions.md new file mode 100644 index 00000000..f29b20b3 --- /dev/null +++ b/.changeset/photographic-cell-regions.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add coherent Photographic Cells regions with separate region and cell sizes, image-guided or seeded selection, and None/Perimeter/Every Cell outline modes. Preserve saved cell layouts and contour settings while new layers start with connected regions and fine perimeter outlines. diff --git a/.changeset/photographic-cells.md b/.changeset/photographic-cells.md new file mode 100644 index 00000000..a2f86161 --- /dev/null +++ b/.changeset/photographic-cells.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Photographic Cells with light/dark/random selection, irregular rectangular cells, full-resolution photographic interiors, alpha cutouts, and optional outlines. Support the effect in runtime configs and grouped compositions. Apply user-curated editor defaults to new Displaced Rings layers while preserving saved settings and renderer fallbacks. diff --git a/.changeset/plotter-rework.md b/.changeset/plotter-rework.md new file mode 100644 index 00000000..8d45e2bf --- /dev/null +++ b/.changeset/plotter-rework.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Rework Plotter into a pen-plotter drawing: Hatch (crosshatch layers by tone), Flow (dense lines following the forms), Contour (smooth tone contours), Squiggle (rows that wave in the darks), Spiral (one continuous spiral whose line thickens with tone) and Stipple (dots by density), with pen width, pressure, wobble, ink bleed, ink, source or three pens, paper color, grain or transparent paper, and seven starting styles. The original parameters and their defaults are kept, so saved plotter layers still open. diff --git a/.changeset/relief-layer.md b/.changeset/relief-layer.md new file mode 100644 index 00000000..f85d818b --- /dev/null +++ b/.changeset/relief-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Relief effect layer that embosses or debosses the image into a lit surface, with height from luminance, scene depth or a cutout's alpha, Depth, Bevel, light angle, elevation and ambient, a single material or the source colors with specular highlights, grain, and optional parallel or radial engraving. Five starting styles (Silver Plate, Letterpress, Blind Emboss, Gold Foil, Source Relief). It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/ring-shapes.md b/.changeset/ring-shapes.md new file mode 100644 index 00000000..00d2994d --- /dev/null +++ b/.changeset/ring-shapes.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Displaced Rings gains ring shapes (Circle, Triangle, Square, Polygon with Sides) alongside the Full/Half cut, per-band Rotation Jitter and Width Jitter driven by Seed, and Lines: thin strokes on the band boundaries that can keep repeating across the canvas beyond the outer band, with width, color and opacity. New controls default to off, so existing layers and saved projects look the same. diff --git a/.changeset/shape-layers.md b/.changeset/shape-layers.md new file mode 100644 index 00000000..fe160ce4 --- /dev/null +++ b/.changeset/shape-layers.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Shape source layer: ellipse, rectangle, triangle, polygon, star, ring and blades silhouettes with color, outline, softness and canvas handles. Renders identically in the editor, exports and the runtime package, and combines with blend modes and layer masks. diff --git a/.changeset/signal-rot-layer.md b/.changeset/signal-rot-layer.md new file mode 100644 index 00000000..962ece4a --- /dev/null +++ b/.changeset/signal-rot-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Signal Rot effect layer for scanner drag and decaying signal: held streaks, snaking wobble, uneven scan speed, torn bands with paper dropouts, chroma drift, crushed levels and line noise, frozen or animated. Three starting styles (Scanner Drag, Signal Rot, Torn Scan). It renders identically in the editor, exports and the runtime package. diff --git a/.changeset/stable-artboard.md b/.changeset/stable-artboard.md new file mode 100644 index 00000000..2c8fc517 --- /dev/null +++ b/.changeset/stable-artboard.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a stable artboard: any composition aspect other than Screen (adaptive) is a fixed document size, centered in the editor and letterboxed in the community player, so framing, masks and text no longer follow the window. Screen mode and older scenes are unchanged. diff --git a/.changeset/svg-shapes.md b/.changeset/svg-shapes.md new file mode 100644 index 00000000..f1f74af1 --- /dev/null +++ b/.changeset/svg-shapes.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Shape layers can draw an SVG: set `shape: "svg"` and give the layer an image `asset` pointing at the file. The SVG becomes a signed distance field, so its edges stay sharp at any size and Softness and Outline work as on the built-in shapes. `svgColorMode: "original"` keeps the file's colors, and `svgPalette` (a JSON map from a file color to a new color) recolors them; `"single"` fills the shape with `color`. diff --git a/.changeset/text-editing.md b/.changeset/text-editing.md new file mode 100644 index 00000000..a7dc9c5f --- /dev/null +++ b/.changeset/text-editing.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Text layers support multiline content, explicit alignment, line height, rotation and font sizes down to 8px, rendered identically in the editor and runtime. The editor adds in-place editing by double-click and canvas handles for position, rotation and size. diff --git a/.changeset/text-texture-resize.md b/.changeset/text-texture-resize.md new file mode 100644 index 00000000..ebd75770 --- /dev/null +++ b/.changeset/text-texture-resize.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Text layers redraw at the right size after the canvas or composition is resized. The text texture kept its first size on the GPU, so a resized canvas showed the text scaled down. diff --git a/.changeset/three-185.md b/.changeset/three-185.md new file mode 100644 index 00000000..8e716e71 --- /dev/null +++ b/.changeset/three-185.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Require three 0.185: the `three` peer dependency is now `^0.185.0` (was `^0.183.0`). Projects still on three 0.183 need to update three together with this release. diff --git a/.changeset/threshold-colors.md b/.changeset/threshold-colors.md new file mode 100644 index 00000000..a0602bf5 --- /dev/null +++ b/.changeset/threshold-colors.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add darkColor and lightColor to Threshold, retaining black/white fallbacks. Recolor the existing threshold field without another render pass; softness, noise, inversion and source coverage retain their behavior. diff --git a/.changeset/transparent-media-bounds.md b/.changeset/transparent-media-bounds.md new file mode 100644 index 00000000..931e9dab --- /dev/null +++ b/.changeset/transparent-media-bounds.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Support transparent borders for contained image and video layers with `params.transparentBounds: true`. Existing runtime configs without the setting retain black borders. diff --git a/.changeset/transparent-text-defaults.md b/.changeset/transparent-text-defaults.md new file mode 100644 index 00000000..5232c2c6 --- /dev/null +++ b/.changeset/transparent-text-defaults.md @@ -0,0 +1,4 @@ +--- +--- + +New editor text uses normal composition over a transparent background. Clarify the background opacity control and preserve the solid fallback for saved text that omits the setting. This editor-only change does not require a package release. diff --git a/.changeset/v3-render-performance.md b/.changeset/v3-render-performance.md new file mode 100644 index 00000000..9757ad16 --- /dev/null +++ b/.changeset/v3-render-performance.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Render the V3 effect layers with less GPU work and no visual change: Connected Dots reads its dot sites from a per-cell pre-pass (about 9x faster at 1080p), and Displaced Rings, Glass, Blur, Plotter, Signal Rot, Outline, Photocopy, Erosion, Lumen Print, Dot Grid and Relief skip work their current settings don't use. Blob Tracking stops requesting continuous rendering once its output settles on a still image, and Annotations only re-read their Edges field when the image below changes. Outline, Flares and Relief no longer read the wrong texture when their shader compiles before the first frame. diff --git a/.gitattributes b/.gitattributes index 807ee35b..7104d8a6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,3 +17,5 @@ # - Prevents merge conflicts caused by line ending differences * text=auto eol=lf +*.hdr binary +*.wasm binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63cabcc7..098b99da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + - git-chad/shader-lab-v3-plan + - git-chad/v3-* push: branches: - main @@ -49,3 +51,24 @@ jobs: - name: Ensure changeset exists for PR changes if: github.event_name == 'pull_request' run: bunx changeset status --since=origin/${{ github.base_ref }} + + composition: + # Linux Chromium/SwiftShader currently drops its GPU instance during these + # texture-backed shader compilations. Keep the full GPU checks on macOS. + runs-on: macos-14 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bunx playwright install chromium + - run: bun run test:composition + - name: Upload rendered compositions + if: always() + uses: actions/upload-artifact@v4 + with: + name: composition-actuals + path: .context/composition-test/ + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..721d46bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Shader Lab development instructions + +Before deciding how a shader behaves or looks, open the relevant images listed in [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). This is an explicit user requirement. Read that guide for reference locations, visual goals, and review criteria; a prose description or passing GPU tests does not replace looking at the images. + +Keep prototype acceptance distinct from completing the visual direction in V3-ROADMAP.md. Preserve the stacked PR workflow; do not merge V3 into main until the plan is complete. diff --git a/V3-ROADMAP.md b/V3-ROADMAP.md new file mode 100644 index 00000000..c681d70b --- /dev/null +++ b/V3-ROADMAP.md @@ -0,0 +1,551 @@ +# Shader Lab V3 — Hoja de trabajo priorizada + +V3 debe facilitar resultados artísticamente atractivos desde la primera inserción y conservar libertad para transformarlos hasta volverlos irreconocibles. Los nuevos efectos serán protagonistas; los actuales seguirán disponibles y combinables. Crear piezas tipográficas con facilidad también es una prioridad práctica. Cada incorporación debe respetar las composiciones guardadas. + +El orden es una **propuesta de ejecución**; las prioridades de producto son revisables. La composición fiable es una dependencia para integrar texto, efectos y 3D. Las fases 1–7 definen el recorrido de V3, con validación propia y cierre global obligatorio. La fase 8 es opcional y no condiciona ese cierre. No se afirman implementaciones, fechas ni rendimiento ilimitado. + +## Flujo de integración y entrega + +Todo el trabajo de V3 se entrega mediante PRs pequeños apilados. Cada nuevo PR parte de la rama del PR anterior y la usa como base. El primer PR de la pila apunta a la rama de integración de V3; un PR principal reúne esa rama y tiene como destino `main`. + +**PR nuevo → PR anterior → rama de integración de V3 → PR principal → `main`.** + +Los PRs pequeños pueden integrarse progresivamente en la rama de V3. Ningún cambio de V3 se integra en `main` hasta completar el alcance obligatorio del plan y su validación global de la fase 7. La fase 8 sigue siendo opcional y no bloquea esa entrega. + +La rama de integración es `git-chad/shader-lab-v3-plan`. La pila comienza con [#151](https://github.com/basementstudio/shader-lab/pull/151), en `git-chad/v3-composition-alpha`, seguido de [#152](https://github.com/basementstudio/shader-lab/pull/152), en `git-chad/v3-transparent-media-bounds`. La base compartida de composición alfa continúa desde esta última rama. Al integrar PRs, actualizar las bases de los descendientes para conservar la pila; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria. + +PR principal: [#150 — V3: designer-focused composition and experimental graphics](https://github.com/basementstudio/shader-lab/pull/150). + +Primer paso de la fase 1: [baselines de composición y mapa del canal alfa](tests/composition/README.md). Esta cobertura inicial no cierra la fase ni sustituye la validación visual con las dieciséis referencias elegidas. + +Avance de 1.3: grupos aislados con hasta ocho niveles, controles del editor, reordenamiento de subárboles, plegado, visibilidad, opacidad, duplicación, deshacer, persistencia v7 y exportación de configuración. Las pruebas cubren estado, hidratación real y píxeles guardados/reabiertos. El usuario confirmó la prueba de aislamiento de foto + halftone, opacidad y plegado. La corrección posterior de arrastre permite entrar/salir de grupos con indicadores de destino y actualiza orden y pertenencia en una sola operación; la [lista manual ampliada](tests/composition/GROUPS-MANUAL-QA.md) sigue disponible para la validación global. + +**Prioridades revisadas con el usuario (17 de septiembre de 2026):** texto transparente por defecto (2.1) implementado en #156 y confirmado por el usuario. Anillos desplazados implementados y validados visualmente; el usuario también confirmó la corrección de arrastre entre grupos. Regiones conectadas y contornos de perímetro en Photographic Cells (3.3.1 A+B) confirmados visualmente por el usuario; pintura manual confirmada visualmente por el usuario, con entrada automática a edición al elegir Paint. El usuario reporta alta carga/calor al combinar efectos: evaluar rendimiento durante reproducción y edición de escenas dinámicas en cada entrega. La decisión inicial de posponer máscaras generales queda reemplazada por la aprobación posterior de 1.4: gradiente, elipse, rectángulo y pincel, con prioridad alta y sin herramienta de pluma. Mantener los modos creativos propios de cada efecto. Las anotaciones técnicas y la revisión de Blob Tracking ganan protagonismo en 6.3; Patterns personalizables queda como exploración de prioridad baja en 6.5. + +### Ampliaciones confirmadas tras feedback de diseñadores + +**Aprobadas como dirección del plan; el estado de cada entrega se detalla abajo.** Orden de trabajo acordado: inicio limpio → máscaras reutilizables y Gradient Map local → herramientas de composición con formas y texto → anotaciones técnicas → investigación de profundidad/parallax. Patterns personalizables se registra con prioridad baja. Este orden revisa prioridades entre fases sin eliminar dependencias ni validaciones; no obliga a terminar toda una fase antes de abordar la siguiente entrega útil. + +| Tema | Alcance acordado | Ubicación | +| --- | --- | --- | +| Inicio limpio | Proyecto vacío con ajustes neutros; demo separada y ajustes globales visibles | 1.0 | +| Máscaras generales | Gradiente lineal/radial, elipse/círculo, rectángulo/cuadrado y pincel; sin pluma | 1.4 | +| Gradient Map local | Capa de efecto, combinable con grupos y máscaras; conservar ajuste global | 2.5.2 | +| Formas | Capas editables y presets; recorte y mezclas fotográficas | 2.7 | +| Texto y composición | Edición directa, etiquetas pequeñas, multilínea, alineación y artboard estable; conexión con grilla opcional | 2.2–2.3, 2.8 y fase 8 | +| Anotaciones técnicas | Puntos, cajas, cruces, etiquetas y líneas; dirección artística destacada, vinculada a Blob Tracking | 6.3 | +| Profundidad y parallax | Investigar mapas importados primero; generación automática después | 6.4 | +| Patterns personalizables | Cargar SVG/imágenes, ordenar una secuencia tonal y elegir tratamiento del color; prioridad baja | 6.5 | + +Las nuevas capturas de referencia están identificadas en [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). La aprobación de estas ideas no implica aceptación visual de las entregas que aún esperan prueba del usuario. + +## Fase 1 — Resolver la base de composición y proteger proyectos existentes + +**Prioridad máxima:** transparencia y grupos sostienen la composición. Resolver primero el inicio limpio y ampliar esa base con máscaras generales simples, sin perder los modos de cobertura propios de cada efecto. La preparación inicial debe ser breve y servir a la ejecución. + +### Referencias visuales obligatorias + +Antes de decidir el comportamiento o la apariencia de cualquier shader, abrir las imágenes pertinentes de [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). El directorio original y la copia local contienen las dieciséis referencias del usuario. Comparar renders con esas imágenes; las pruebas técnicas no sustituyen la revisión visual ni dan por terminada una familia. + +### 1.0 Ofrecer un inicio realmente limpio — primera corrección + +El usuario reporta que una sesión nueva conserva ajustes globales de la escena demo, alterando su trabajo sin una indicación clara. La inspección confirma que `Start fresh` aplica `getDefaultProjectFile()`, incluidos sus ajustes globales; distinguir ese comportamiento de restaurar un proyecto guardado. + +Ofrecer **New blank project** con escena vacía y ajustes globales neutros, manteniendo la demo como elección separada. Hacer evidente cuándo hay ajustes globales activos y permitir restablecerlos. Definir claramente el recorrido de primera visita, nuevo proyecto y restauración de sesión; no borrar ni neutralizar los ajustes deliberados de archivos existentes. + +**Implementado y aceptado por el usuario (30 de septiembre de 2026):** primera visita vacía; menú Project con New blank project / Open demo también en Actions móvil; aviso de gradación global y reset completo reversible que conserva fondo, composición y calidad de preview. Crear/abrir un documento reinicia el historial, incluidas entradas pendientes. Autosave admite proyectos vacíos y los persiste inmediatamente al comenzar uno, evitando restaurar una demo antigua por encima de un vacío nuevo; los proyectos importados/restaurados mantienen su gradación. [Prueba manual](tests/composition/CLEAN-PROJECTS-MANUAL-QA.md). + +**Cierre:** una foto insertada en un proyecto vacío no hereda gradación de la demo. Abrir la demo y restaurar un proyecto conservan sus respectivos ajustes. Verificar también historial, autosave y guardado/reapertura del nuevo proyecto. + +### 1.1 Preparar pruebas y referencias mínimas + +Reunir las dieciséis imágenes elegidas y pruebas con fotografías, retratos, objetos, tipografía y video, variando luz y detalle. Guardar escenas existentes y resultados de referencia para comprobar su apariencia desde el primer cambio. + +### 1.2 Comprobar el estado actual y corregir el canal alfa + +Revisar la transparencia durante composición y efectos. Una inspección previa limitada en otro checkout detectó opacidad forzada, rellenos negros y procesamiento compartido; orienta esta comprobación, sin reproducir todos los problemas. + +Permitir regiones vacías junto a contenido totalmente opaco. Bajar la opacidad global no sustituye la transparencia por píxel. Los efectos deben conservarla o transformarla con sentido y ofrecer fondos transparentes cuando generen fondos y corresponda. Esto no incluye eliminar automáticamente el fondo de fotografías arbitrarias. + +### 1.3 Introducir grupos reales con alcance propio + +Los grupos con alcance propio son **requisitos confirmados**, pedidos explícitamente por un colega diseñador. Las máscaras de grupo también están confirmadas: tras una postergación inicial, el usuario aprobó retomar máscaras generales simples en 1.4, junto a los modos creativos propios de los efectos. Se propone composición aislada por defecto: procesar los contenidos pertinentes del grupo y luego integrarlo al conjunto. Sus efectos no deben afectar capas externas. Agregar carpetas sobre una cadena global de filtros no resuelve el requisito. + +Incluir organización, reordenamiento, plegado, visibilidad, opacidad, deshacer y guardado. Definir la profundidad de anidamiento durante la implementación, sin prometer reproducir todo Photoshop. + +**Ampliación acordada para más adelante — Pass Through:** añadir un modo opcional de grupo que permita a sus efectos procesar las capas inferiores externas, dentro del contexto de composición de su padre. Mantener **Isolated** como valor predeterminado, también para proyectos guardados que no indiquen un modo. Esta ampliación no bloquea las entregas en curso. + +Ofrecer una elección explícita entre Isolated y Pass Through, con una explicación breve de su alcance. Un grupo con Ink y Displaced Rings sobre una imagen externa debe dejarla intacta en Isolated y procesarla al elegir Pass Through. Las capas superiores y las que estén fuera de un ancestro aislado deben conservar su independencia. Definir el comportamiento de opacidad, mezcla y recorte del grupo antes de implementarlo; no asumir que basta con aplanar sus hijos. + +Validar grupos anidados y cambios de modo, visibilidad, reordenamiento, deshacer/rehacer, guardado/reapertura y paridad entre editor, runtime y exportación. Conservar la apariencia de todos los grupos aislados existentes. + +### 1.4 Máscaras generales simples y modos creativos por efecto — prioridad alta + +**Decisión actual confirmada:** retomar máscaras generales reutilizables. Reemplaza la postergación anterior. Mantener únicamente cuatro herramientas: **gradiente** (lineal y radial), **elipse** (incluye círculo), **rectángulo** (incluye cuadrado) y **pincel**. **Sin pen tool/pluma ni edición de nodos Bézier.** La selección automática de sujetos o regiones queda para una extensión posterior, no para esta primera entrega. + +Distinguir dos usos mediante controles claros: + +- **Aplicar un efecto dentro de una máscara:** por ejemplo, pintar Halftone sobre parte de una fotografía; fuera de la máscara sigue visible la imagen sin ese efecto. +- **Recortar contenido de una capa o grupo:** fuera de la máscara hay transparencia que revela las capas inferiores. + +Definir asignación y alcance, inversión, desactivación y edición directa de posición/tamaño/dirección según la herramienta. Mantener pocos controles; evaluar suavidad de borde y transiciones del gradiente según el resultado. Reutilizar la interacción y el código útil del pincel de Cells —Reveal/Erase, tamaño, edición explícita y un trazo por acción de historial— sin asumir que ya funciona como máscara general. Conservar las pinturas guardadas y los modos específicos de Cells y Rings; evitar acoplar la nueva máscara a su geometría. + +```text +Texto — fuera del grupo +Grupo «Retrato» — máscara de elipse que recorta contenido + Halftone — máscara de pincel que limita el efecto + Fotografía +Fondo — fuera del grupo +``` + +En esta composición, el pincel limita Halftone sin borrar la foto; la elipse recorta el grupo entero. Texto y fondo quedan fuera de ambos alcances. La selección de una capa de formas como máscara se conecta con 2.7; la asignación arbitraria de cualquier silueta de capa requiere una decisión posterior. + +**Cierre:** gradiente, elipse, rectángulo y pincel funcionan con los dos usos definidos, inversión/desactivación, zoom y desplazamiento. Bordes suaves sin halos negros ni pérdida incorrecta de alfa; grupos aislados y máscaras anteriores conservan su apariencia. Probar historial, duplicación, hidratación real, guardado/reapertura y paridad editor/runtime/PNG/video. Las máscaras estáticas se reutilizan mientras cambia la fuente de video; la pintura manual no implica seguimiento automático del sujeto. + +**Implementado y aceptado por el usuario (30 de septiembre de 2026):** cada capa (grupos incluidos) tiene una máscara opcional con forma None / Linear gradient / Radial gradient / Ellipse / Rectangle / Brush, alcance **Limit effect** / **Cut content** (solo efectos; en fuentes y grupos ambos equivalen a recortar la propia capa), Invert, Enabled, centro, tamaño, rotación y Feather (elipse y rectángulo). Manijas directas en el lienzo: centro, eje X (rotación y ancho), eje Y (alto); el gradiente lineal usa inicio/fin. El pincel reutiliza la interacción de Cells (Reveal/Erase, Brush Size, Clear, un trazo por acción de historial, entrada automática al elegir Brush) con el mismo códice compacto, sin acoplarse a la geometría de Cells. Coordenadas centradas en unidades del lado corto, iguales a Paint; la máscara sigue el viewport hasta resolver el artboard estable (2.8). Interpolación premultiplicada sin halos negros; el modo antiguo **Mode → Mask** no cambia. Editor y runtime comparten `renderer/layer-mask.ts`; mover una máscara solo actualiza uniforms. Persistencia .lab, duplicado, historial, export PNG/video, runtime y MCP `update_layer.mask`. Referencias revisadas: 17 y 20. [Prueba manual](tests/composition/LAYER-MASKS-MANUAL-QA.md). Máscaras animables por timeline: implementadas el 23 de septiembre de 2026 (ver 6.4). Feather en máscaras de pincel: pedido por el usuario el 23 de septiembre de 2026, implementado (tres anillos de muestras en GPU, animable); con 0 el borde queda duro. Pendiente: selección de una capa de formas como máscara (2.7). + +### 1.5 Anticipar las decisiones de viabilidad 3D + +Probar brevemente formatos, materiales, animaciones, conservación de recursos y geometría SVG. Registrar decisiones abiertas para las fases 4 y 5 sin demorar la composición. Una entrada de catálogo o tipo de capa no demuestra soporte completo de modelos. + +**Cierre de la base:** huecos transparentes y contenido opaco conviven; grupos y efectos mantienen su alcance al reordenar, deshacer, guardar, reabrir y exportar. Las escenas anteriores conservan su apariencia y los bordes suaves quedan limpios. Las máscaras generales acordadas y cada modo creativo incorporado deben superar esas mismas pruebas; la selección automática y una herramienta de pluma no forman parte del alcance inicial. + +## Fase 2 — Facilitar las piezas tipográficas y pulir las capas actuales + +**Prioridad temprana:** resolver la inserción y edición de texto aporta valor directo al diseño, junto con mejores efectos iniciales. Priorizar cambios útiles y acotados. Las pruebas pueden avanzar durante la fase 1; la integración depende de su tratamiento de transparencia, sin duplicarlo. + +### 2.1 Insertar texto sobre fondo transparente + +El usuario reporta texto blanco sobre negro y la necesidad de recurrir a máscaras, Screen o cambiar manualmente el fondo. El requisito es insertar texto visible, con contenido opaco y color editable sobre fondo transparente, sin esos rodeos. Mantener el fondo sólido como una opción deliberada y conservar el uso de texto como máscara cuando se elija expresamente. + +La inspección inicial confirmó que `layers.ts` creaba texto en modo máscara y `layer-registry.ts` definía fondo negro con alfa 1. `text-pass.ts` ya distingue el alfa del fondo y dibuja el texto opaco; admite color editable y posición mediante anclaje y desplazamiento. La corrección debe cambiar los valores de capas nuevas y conservar el fondo sólido de archivos anteriores que omitan ese parámetro. + +### 2.2 Componer texto directamente en el lienzo + +**Dirección aprobada:** seleccionar, arrastrar y redimensionar texto directamente, con controles de alineación y sincronización con posición, anclaje y zoom. Validar deshacer/rehacer y posición guardada. Resolver primero un conjunto útil dentro del editor actual; una sección separada de **Design** sigue como alternativa de producto para evaluar más adelante, no como segunda interfaz comprometida. + +La grilla de la fase 8 se conecta con esta edición y con las formas de 2.7: sirve de referencia para márgenes, columnas y alineaciones. El snapping debe poder desactivarse y conservar colocación libre. La edición directa y la alineación básica deben seguir siendo útiles sin activar una grilla; su carácter opcional no cambia por esta conexión. + + +**Implementado el 22 de septiembre de 2026 y aceptado por el usuario el 30 de septiembre de 2026:** doble clic en el lienzo edita el texto en su lugar (fuente, tamaño y posición reales); Enter agrega línea, Cmd+Enter o clic afuera confirma, Esc restaura; toda la edición es un paso de historial. Manijas compartidas con máscaras y formas: centro mueve (actualiza Offset), eje X rota, eje Y cambia Font Size. Botón **Edit text** en el panel; el campo Text es multilínea. La posición depende del artboard estable (2.8), así que preview y export coinciden. +### 2.3 Ampliar controles tipográficos útiles + +Conservar fuente, tamaño, peso, espaciado, color, anclaje y desplazamiento. Incorporar etiquetas más pequeñas y texto multilínea; revisar el límite de 32 caracteres y el mínimo de 48px, que impiden anotaciones finas en los estudios actuales. Definir límites útiles al implementar, sin prometer un motor tipográfico completo. Mantener claro el control de fondo transparente/sólido y comprobar composición, edición y exportación con bloques de texto. + + +**Implementado el 22 de septiembre de 2026 y aceptado por el usuario el 30 de septiembre de 2026:** Font Size mínimo 8 (antes 48), Align (Follow anchor / Left / Center / Right), Line Height y Rotation alrededor del punto de anclaje; texto multilínea con saltos manuales. El trazado vive en `renderer/text-layout.ts`, compartido por editor y runtime; con una sola línea y valores por defecto la salida es idéntica a la anterior, y los fixtures antiguos no cambian. Pendiente: ajuste automático de línea. El recorte de texto muy grande rotado se revisó el 13 de octubre de 2026: el texto solo se corta donde sale del cuadro, igual que cualquier capa, y el editor coincide con el PNG exportado, así que no requiere cambios. [Prueba manual](tests/composition/TEXT-EDITING-MANUAL-QA.md). +### 2.4 Ajustar halftone como primer efecto + +Ajustar parámetros sobre el material de prueba hasta lograr resultados útiles con luces y detalles diversos. Introducir pequeñas mejoras algorítmicas solo si las pruebas lo justifican; no plantear una reescritura general. + +### 2.5 Extender el criterio y facilitar la edición + +Extender el criterio a los demás efectos. Organizar controles con etiquetas comprensibles, entradas numéricas, deslizadores apropiados y restablecimiento. Aplicarlo también a capas nuevas: valores curados como puntos de partida editables hasta la abstracción extrema. + +#### 2.5.1 Colores editables en Threshold + +**Implementado y aceptado por el usuario (30 de septiembre de 2026):** dos colores editables en la capa **Threshold** existente: **Color oscuro** y **Color claro**, reemplazando la salida fija negro/blanco por una combinación personalizada. Mantener negro y blanco como valores iniciales y como fallback en proyectos anteriores. + +Conservar el funcionamiento del umbral, la inversión y la transparencia; cambiar la paleta solo modifica los colores de salida. Integrar los selectores con deshacer/rehacer, guardado/reapertura y exportación editor/runtime. Evitar un pase de render adicional para esta recoloración, teniendo en cuenta fuentes de video y escenas dinámicas. + +**Cierre:** con los colores iniciales, las escenas existentes se ven iguales; al elegir, por ejemplo, azul y rosa, ambas regiones adoptan esos colores sin desplazar sus límites. La paleta elegida sobrevive al historial, reapertura y exportación. + +Entrega: controles Dark Color / Light Color, fallbacks negro/blanco y mezcla dentro del pase existente, sin render adicional. Referencia 01 revisada y render azul/rosa inspeccionado. Pruebas de GPU editor/runtime, video decodificado, hidratación real, historial, guardado/reapertura y PNG; [prueba enfocada y límites](tests/composition/THRESHOLD-COLORS-MANUAL-QA.md). + +#### 2.5.2 Gradient Map como capa de efecto — prioridad alta + +**Aprobado:** trasladar la capacidad de mapeo tonal global a una capa de efecto reordenable, con colores/puntos del gradiente editables y alcance controlado por grupos y máscaras. Conservar también el ajuste global y la apariencia de proyectos existentes. Distinguir esta recoloración de la capa Gradient, que genera un campo de color. + +Ejemplo de aceptación: Gradient Map recolorea únicamente la foto de su grupo; al enmascararlo con pincel, el resto de la foto conserva sus colores originales y las capas externas no cambian. Probar opacidad, inversión del mapeo si se ofrece, historial, persistencia y exportación con fotografía/video. + +Explorar una paleta de apariencia térmica a partir de la referencia Brockhampton compartida. El falso color tonal puede funcionar sin profundidad; colorear un depth map es otra interpretación. No afirmar que un mapa de profundidad mide temperatura ni que reproduce automáticamente la distribución de color de esa portada. + +**Implementado y aceptado visualmente por el usuario (22 de septiembre de 2026):** nueva capa de efecto **Gradient Map** (catálogo Core, tras Threshold) con rampa editable (2–5 paradas, GradientRamp compartido con el ajuste global), presets Thermal / Duotone / Sepia / Neon / Grayscale (los valores iniciales quedan a cargo del usuario), Amount e Invert. Mapea la luminancia Rec.709 con una LUT lineal de 256 muestras compartida con el Color Map global (`renderer/color-map-lut.ts`), preserva la cobertura de entrada y respeta grupos y máscaras; el ajuste global y los proyectos existentes no cambian. Editor/runtime, persistencia, duplicado, historial, export y MCP por parámetros normales. Referencia 18 revisada: paleta de apariencia térmica sin inferir profundidad. [Prueba manual](tests/composition/GRADIENT-MAP-MANUAL-QA.md). Paradas animables implementadas el 30 de septiembre de 2026 (ver 6.6). + +### 2.6 Preservar las composiciones guardadas + +Aplicar los nuevos valores únicamente a capas recién creadas y comparar proyectos anteriores con sus resultados de referencia, incluidos textos con fondo sólido, máscaras y modos de mezcla elegidos previamente. + +### 2.7 Capas de formas y composiciones fotográficas + +**Aprobado por etapas:** formas editables y una pequeña biblioteca de presets, con color, posición, tamaño y rotación. Reutilizar la interacción de composición y las máscaras simples de 1.4. Permitir utilizar formas para enmascarar y para combinar fotografía/color, tomando Lovedance como referencia visual. + +Probar doble exposición con las máscaras y modos de mezcla disponibles antes de añadir un efecto específico. Mantener el alcance simple: no incorporar pluma, edición Bézier ni un Illustrator completo. Dibujar geometría vectorial libre queda fuera de esta entrega; el pincel acordado pinta cobertura de máscara, no crea trazados vectoriales. + +**Implementado (primera etapa) y aceptado visualmente por el usuario (22 de septiembre de 2026), que pide más formas:** capa fuente **Shape** (fila de fuentes del selector) con siete presets procedurales —Ellipse, Rectangle (Corner Radius), Triangle, Polygon (Sides), Star (Points, Inner Radius), Ring (Thickness) y Blades (Blades, Twist, Blade Width, Hub, guiño a Lovedance)—, Color, Outline (solo borde) y Softness (antialias a 0). Center/Size/Rotation en unidades de composición (las mismas que las máscaras), editables con las manijas del lienzo compartidas con las máscaras (`geometry-handles.tsx`): un arrastre = un paso de historial, Esc cancela. SDFs en un solo shader (cambiar de forma no recompila), cobertura como alfa recto para que los modos de mezcla sobre fotografía funcionen (doble exposición con Multiply/Screen/Color). Editor y runtime idénticos; persistencia, duplicado, export y máscaras por capa. Referencias 01 y 20 revisadas: silueta plana sobre fotografía azul; la curva de Blades es una interpretación, no una reproducción. [Prueba manual](tests/composition/SHAPE-LAYERS-MANUAL-QA.md). Pendiente: usar una capa Shape como máscara de otra capa. **Solicitado por el usuario al aceptar la primera etapa (21 de septiembre de 2026):** formas a partir de SVG subidos por el usuario, con color editable donde el archivo lo permita; convive con los presets procedurales y con la carga de SVG como imagen que ya existe. Decidir rasterización (como la capa Image) frente a distancia firmada para bordes nítidos, softness y outline; mantener fuera la edición de nodos. + +### 2.8 Artboard estable y relación con la grilla + +Definir un área de composición de tamaño estable para conservar encuadre y posiciones de texto, formas y efectos al cambiar el tamaño de la ventana. Los estudios actuales se reencuadran porque el canvas sigue el viewport; separar tamaño del documento, zoom y vista previa. Preservar las escenas anteriores que dependen del comportamiento adaptable y comprobar correspondencia con exportación. + +La grilla opcional de la fase 8 debe utilizar el mismo espacio del documento para alinear texto y formas. Esta base se desarrolla dentro del editor existente antes de decidir si una sección Design separada aporta valor. + +**Implementado y aceptado por el usuario (22 de septiembre de 2026):** `compositionAspect` distingue **Screen (adaptive)** —comportamiento anterior, conservado por las escenas antiguas y la demo— de los artboards fijos (presets y Custom), cuyo tamaño real es la `composition` guardada y se edita en Scene → Composition → Size. El canvas del editor se ajusta y centra en el viewport con pasteboard; los pases reciben el documento como `logicalSize`, así preview, máscaras, Paint, texto y export coinciden. Cambiar de preset recorta el documento actual al nuevo ratio; las escenas antiguas con preset no-screen pasan de un marco guía a un artboard real (su export adopta ese tamaño). Los proyectos nuevos empiezan en 16:9 1920×1080; los estudios de `public/examples/v3` ahora son artboards Custom 720×960. El visor de la comunidad aplica letterbox a los artboards fijos. Zoom 100% = ajuste al viewport. [Prueba manual](tests/composition/ARTBOARD-MANUAL-QA.md). **Render a resolución de zoom (13 de octubre de 2026):** al acercar, el editor sube la resolución del render en proporción al zoom cuando el zoom se detiene (150 ms), con un tope de 4096 px en el lado largo para acotar el coste; a 100% o menos no cambia, y la exportación no se ve afectada. Pendiente: grilla opcional (fase 8). + +**Cierre:** el texto nuevo se integra sobre fondo transparente sin activar máscaras ni Screen; su contenido permanece opaco y su color es editable. La edición directa coincide con controles, deshacer y posición guardada; las formas y el artboard mantienen una composición consistente. Los efectos actuales ofrecen valores iniciales útiles, conservan libertad de edición y combinación, y las escenas anteriores —incluidos sus textos— mantienen su apariencia. Resolver estos puntos no depende de la grilla opcional. + +## Fase 3 — Explorar la primera tanda artística e incorporarla al catálogo + +**Prioridad revisada:** anillos y celdas ya fueron seleccionados con el usuario. Revelado alterado sigue como familia candidata; su alcance debe confirmarse antes de incorporarla. + +### 3.1 Traducir las referencias en criterios visuales + +Explorar deterioro fotográfico, imágenes sintéticas o escultóricas y geometría, inspirados en afiches experimentales, discos, revistas de música electrónica, Photoshop y diseño de finales de los noventa y los dos mil. Admitir registros claros, oscuros, sobrios y coloridos. + +La base principal son las dieciséis imágenes elegidas, más Lovedance —forma roja sobre fotografía azul, atravesada por luces— y el collage monocromo de foto difusa, curvas técnicas y tipografía precisas. Transformar fotografía con geometría, tono, textura y composición sin limitarse al cyberpunk o una estética retro. Los archivos iniciales fueron exploratorios: solo dos ejemplos interesaron y una página de Paradiso falló. + +### 3.2 Probar las tres familias recomendadas + +| Familia candidata | Resultado y referencias | +| --- | --- | +| Anillos desplazados y rotados | Bandas circulares transformables; afiche fotográfico azul y negro con círculos concéntricos desalineados (`16-anillos-desfasados-azules.png`). Incluir en la exploración semidiscos de diferentes tamaños y desplazamientos, con huecos transparentes como modo propio del efecto. | +| Recortes por celdas o bloques | Revelar bloques con detalle fotográfico interior y contornos opcionales. El puente presenta siluetas escalonadas, no grandes píxeles planos; el afiche blanco combina regiones de color, bordes celulares y puntos finos. | +| Revelado fotográfico alterado | Inversiones tonales parciales, expansión de luces y sombras, neblina y erosión; James Blake verde, retrato azul difuso con anotaciones y retrato gris de apariencia solarizada. **Se entrega dentro de Lumen Print (ver consolidación en 3.4).** | + +Los nombres describen resultados; no establecen las técnicas originales de producción. + +### 3.3 Validar libertad y combinaciones + +Exigir el caso de **48 anillos**, aunque la referencia muestre seis u ocho. Explorar centro, radios, anchos, distribución, rotaciones individuales o progresivas, traslación, escala, separaciones, superposición e irregularidad. Evitar límites estéticos arbitrarios y evaluar los límites técnicos reales. + +Permitir destruir completamente el reconocimiento de la imagen. Preparar combinaciones editables, como anillos + dos tintas + erosión, usando componentes disponibles y ampliándolas al incorporar familias posteriores. + +Separar la transformación de la fotografía dentro de cada banda del modo que recorta su cobertura. Probar anillos completos y semidiscos, con desplazamiento y rotación, y definir controles útiles para revelar/ocultar regiones. Los huecos deben mostrar el contenido inferior y los efectos deben respetar los límites del grupo. Este trabajo amplía la exploración de anillos existente; no agrega otra familia ni exige implementar primero máscaras geométricas genéricas. + +**Primer prototipo implementado y validado visualmente por el usuario (valores iniciales a cargo del usuario):** Displaced Rings admite 1–128 bandas (48 comprobadas), anillos/semidiscos, centro, radio, distribución, desplazamiento alternado/progresivo/aleatorio con semilla, rotación global y por banda, escala progresiva, separación y bordes suaves. Output distingue Distort y Cutout; dentro de un grupo, Cutout revela las capas externas por los huecos. Editor y runtime comparten el comportamiento, con pruebas de píxeles, hidratación real, deshacer, guardado/reapertura y PNG. El catálogo incorpora una vista previa fotográfica y muestra el efecto primero. Ver [alcance y prueba manual](tests/composition/DISPLACED-RINGS-MANUAL-QA.md). + +Esto no cierra la fase 3: quedan la curaduría visual con las referencias completas, combinaciones con otros efectos, rendimiento con fotografías/video a resoluciones representativas y la selección de las otras familias. El prototipo usa transformaciones progresivas; no incluye controles individuales por anillo ni inversión de cobertura. + +**Valores iniciales de anillos elegidos por el usuario:** las capas nuevas usan 22 semidiscos, radio 2, rotación por anillo de 45°, desplazamiento y separación 0, con Output en Distort. Mantener los parámetros de escenas existentes y los valores anteriores cuando falten campos en archivos guardados. + +**Primer prototipo de celdas aceptado como base útil por el usuario; corregido el exceso de grosor de contornos al cambiar Gap:** Photographic Cells selecciona bloques rectangulares según zonas claras, oscuras o azar con semilla. Conserva el detalle fotográfico interior y permite ajustar tamaño, proporción, irregularidad de filas, separación, bordes suaves, inversión y contornos. Cutout revela las capas externas al grupo; Keep Image conserva la fotografía y superpone únicamente los contornos seleccionados. Incluye catálogo, editor/runtime, guardado/reapertura y PNG. Ver [alcance y prueba manual](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). No cierra la familia: el usuario señala que todavía no alcanza las referencias. Quedan regiones fotográficas conectadas con siluetas escalonadas y contornos de perímetro (puente, `11-puente-recortes-geometricos.png`), regiones amplias de color con bordes celulares y puntos finos (afiche blanco, `05-recortes-celdas-color.png`), curaduría con las imágenes abiertas y validación temporal/de rendimiento con video. La entrega A+B detallada abajo añade regiones conectadas y contornos sin divisiones internas; el usuario confirmó su utilidad visual con una composición propia de águila, anillos, recortes y trama de puntos. Combinar capas para color/tramas cuando resulte más simple. La selección dirigida mediante Paint ya está implementada y aceptada; el tratamiento editorial completo sigue pendiente. + +#### 3.3.1 Evolucionar Photographic Cells hacia regiones y revelado dirigido + +**Dirección acordada con el usuario; A+B+C implementados y confirmados visualmente por el usuario.** La capa incorpora regiones automáticas, contornos de perímetro y pintura manual como otra fuente de selección, útil aunque el modo automático funcione bien. Mantener pocos controles y reutilizar la geometría, transparencia y composición existentes. + +**Objetivo visual:** abrir y comparar `11-puente-recortes-geometricos.png` y `05-recortes-celdas-color.png` antes de decidir apariencia o valores iniciales. En el puente, conservar fotografía reconocible dentro de una silueta conectada con escalones y un contorno fino de color. En el afiche blanco, formar regiones amplias de color con bordes celulares, pequeños fragmentos alrededor y una trama mucho más fina que las celdas. + +**Estado de A+B:** Regions utiliza un campo espacial coherente con semilla o tonos interpolados de la imagen; Region Size controla los parches y Cell Size los escalones. Outline ofrece None / Perimeter / Every Cell, con contornos alrededor de huecos y sin aristas internas a Gap 0, incluidas filas irregulares. Los valores iniciales nuevos favorecen regiones conectadas; hidratación y runtime conservan la apariencia de configuraciones anteriores. Se revisaron renders fotográficos sobre blanco y una combinación exploratoria con halftone. Paint ya está aceptado; Edge Scatter, los refinamientos de D y los estudios editables del afiche fueron aceptados por el usuario el 30 de septiembre de 2026. Ver [prueba manual, cobertura y límites de rendimiento](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). + +**A. Regiones automáticas — primera entrega** + +- Añadir un modo **Regions** que seleccione conjuntos de celdas vecinas y produzca parches continuos. Explorar selección guiada por la imagen y patrones espaciales coherentes con semilla; conservar los modos actuales de selección por celda. +- Separar **Region Size**, que determina la escala de los parches, de **Cell Size**, que determina el tamaño de los escalones del borde. Mantener detalle fotográfico completo dentro de cada región. +- Reutilizar Threshold, inversión y Seed cuando sean pertinentes; mostrar solo los controles relevantes al modo elegido. A Gap 0, las celdas seleccionadas contiguas deben formar una superficie continua. +- Separar internamente la selección de regiones de la generación de celdas y contornos. Tanto la selección automática como la pintura posterior deben alimentar el mismo resultado geométrico. +- Evaluar los resultados con varias fotografías y regiones de color. La selección tonal o procedural no promete identificar sujetos: aislar exactamente el puente requiere una selección dirigida o trabajo posterior de detección. Si el automático no resulta útil, documentar el límite y priorizar pintura sin bloquear toda la mejora. + +**B. Contornos integrados en Photographic Cells — junto con regiones** + +- Mantener el contorno dentro de este efecto: necesita conocer la selección y la vecindad de sus celdas. Un efecto genérico para delinear imágenes, texto o grupos queda como posibilidad futura fuera de esta entrega. +- Controles: **Outline → None / Perimeter / Every Cell**, **Width** y **Color**. Perimeter sigue la silueta revelada; Every Cell conserva la apariencia actual de contornos por celda. +- En Perimeter, eliminar aristas compartidas entre celdas seleccionadas cuando formen una región continua. Delinear también los límites de huecos reales dentro de la región. Comprobar uniones, esquinas y filas irregulares sin costuras internas. +- Definir y comprobar el comportamiento con Gap positivo sobre la cobertura resultante: las separaciones reales pueden crear contornos, sin inflar el grosor. Conservar la corrección reciente de antialiasing, el alfa de la fuente y el comportamiento de Cutout / Keep Image. +- Aplicar los mismos modos de contorno a regiones automáticas y pintadas. Mantener la apariencia de proyectos anteriores: al faltar el nuevo selector, un contorno existente conserva Every Cell y un ancho cero sigue sin dibujar contorno. + +**C. Pintura manual — implementada y confirmada visualmente por el usuario** + +Implementación: Layout Paint entra automáticamente a edición; Done permite salir y Edit Paint retomarla. Reveal/Erase, Brush Size, Clear reversible, Edit/Done, guía tenue temporal y un trazo por acción de historial. Cobertura compacta persistida en parámetros y runtime, textura GPU reutilizada sin reconstruirla por cambios del video. Mantiene el espacio de la grilla al hacer zoom, desplazar o redimensionar; cambiar Cell Size no borra la pintura. Referencia revisada: 11 (puente). [Prueba y límites](tests/composition/CELL-PAINT-MANUAL-QA.md). No cerrar D ni asumir seguimiento del sujeto. + +- Añadir **Paint** como modo de selección de la capa. El usuario pinta dónde quiere revelar la fotografía; la geometría convierte esa área en bloques escalonados. Cambiar Cell Size ajusta la fidelidad al trazo sin borrar lo pintado. +- Interfaz mínima: **Reveal / Erase**, **Brush Size** y **Clear**, con una acción explícita para entrar y salir de pintura. Mostrar la huella del pincel y evitar conflictos con selección, desplazamiento y zoom del lienzo. +- Mantener la pintura alineada en coordenadas de composición al cambiar zoom, tamaño de vista o resolución de exportación. La pintura representa cobertura de esta capa; no incorpora seguimiento automático del sujeto en video. +- Un trazo debe poder deshacerse/rehacerse como una sola acción; Clear también debe ser reversible. Conservar lo pintado al cambiar temporalmente de modo y definir su persistencia en duplicación, guardado/reapertura e hidratación real del editor. +- Incluir la selección pintada en el archivo de proyecto y en la configuración/recursos exportados al runtime. Vista previa, PNG y render de video deben reproducirla sin depender del estado temporal del editor. +- Esta herramienta es un modo de revelado propio de Photographic Cells. No reactiva el trabajo pospuesto de máscaras geométricas genéricas ni exige la revisión de Blob Tracking. + +**D. Refinamientos visuales — implementados y aceptados por el usuario (30 de septiembre de 2026)** + +Se abrieron las referencias 05 y 11 y se compararon renders reales. Se incorporó **Edge Scatter** (0–1, inicial 0) para Regions y Paint: fragmenta el perímetro mediante un desplazamiento acotado del punto de selección, sin mover la geometría ni el detalle de la fotografía. Reutiliza Seed; no modifica la pintura guardada. Cero conserva el resultado anterior, y Individual Cells lo ignora. Sin pases, texturas ni muestras de fuente adicionales; no depende del tiempo. Detalles pintados muy estrechos pueden fragmentarse con valores altos. + +[Dos estudios editables](public/examples/v3/README.md) muestran campos de color con trama fina y revelado fotográfico dirigido, con tipografía y marcas SVG separadas. Cells se aplica después del Halftone existente dentro del grupo: los puntos son mucho más pequeños que las celdas y respetan su cobertura. No hizo falta añadir más controles de trama a Cells. Las referencias no se incluyen como assets; se utiliza fotografía existente de la app y linework original. + +La curaduría demuestra esas cualidades, no cierra toda la dirección artística ni reproduce los afiches. Sigue pendiente la aceptación del usuario, el acabado analógico más rico y la validación nativa de rendimiento/exportación temporal de 7.2. El mínimo tipográfico actual de 48px limita anotaciones finas; retomar el ajuste acotado de 2.3. La curaduría también confirma que el canvas sigue el viewport: las escenas importadas se reencuadran según la ventana. El artboard estable aprobado en 2.8 debe resolverse antes de presentar estas escenas como plantillas de afiches fijos. [Prueba manual, compatibilidad y límites](tests/composition/CELL-SCATTER-MANUAL-QA.md). + +**Criterios de exploración utilizados:** + +- **Edge Scatter:** probar un único control que fragmente celdas cerca del perímetro, preservando las regiones principales. Comparar con los pequeños cuadrados alrededor de las manchas del afiche blanco antes de decidir su incorporación. +- **Puntos finos:** probar primero una composición con la capa de halftone existente, manteniendo una escala de trama menor que Cell Size. Evaluar el orden y el alcance del grupo para que la trama respete la región revelada. Añadir controles propios solo si esa combinación no alcanza un resultado útil. +- Color, anotaciones técnicas y tipografía pueden venir de otras capas. Preparar composiciones editables que demuestren el conjunto sin recargar este efecto. + +**Orden y aceptación:** entregar A+B primero en un PR apilado; revisar renders y obtener feedback del usuario. Continuar con C en una entrega posterior; evaluar D con esas herramientas disponibles. No considerar implementada ninguna mejora por haberla documentado. Mantener todos los PR dentro de la pila V3 hasta completar el plan. + +**Criterios de cierre de esta mejora:** + +- Una región de varias celdas a Gap 0 muestra fotografía continua, contorno exterior fino y ausencia de divisiones internas en Perimeter; Every Cell reproduce las divisiones deliberadamente. Comprobar regiones separadas, huecos y filas irregulares. +- Region Size cambia la escala de los parches y Cell Size cambia los escalones sin convertir el interior en colores planos. Comparar capturas con las referencias 05 y 11, incluyendo una composición sobre blanco con amplio espacio vacío. +- En Paint, poder revelar un detalle elegido, borrar parte, cambiar Cell Size y zoom, deshacer/rehacer, guardar/reabrir y exportar conservando el área elegida y sus contornos. +- Mantener aislamiento de grupos, transparencia de la fuente, ancho de contorno estable al variar Gap, compatibilidad de proyectos anteriores y equivalencia editor/runtime. Ampliar pruebas de GPU, hidratación mediante `applyLabProjectFile`, historial y exportación según cada entrega. +- Medir respuesta interactiva con fotografía y video a resoluciones representativas; comprobar estabilidad temporal de regiones automáticas. Documentar límites junto a las capturas revisadas. Pasar `bun run check` y `bun run test:composition` al implementar; la revisión visual del usuario sigue siendo necesaria. + +**Formas, jitter y líneas en anillos (23 de septiembre de 2026, aceptadas por el usuario el 30 de septiembre de 2026).** El usuario sentía que a la capa le faltaba algo frente a la referencia 16. Releída: cada banda es la misma foto girada un ángulo propio e irregular (no una espiral regular), los anchos varían y líneas finas concéntricas marcan cada borde y siguen por todo el afiche negro más allá de la foto. Se añadieron Shape (Circle, Triangle, Square, Polygon con Sides; el control anterior pasa a llamarse Cut: Full / Half), Rotation Jitter y Width Jitter por banda con Seed, y Lines (None, Band edges, Edges and beyond) con ancho, color y opacidad. Todo apagado por defecto: las capas y proyectos existentes no cambian. Estudios con Threshold azul sobre negro dentro del grupo reproducen el lenguaje de 16 con círculos; cuadrados y triángulos dan marcos girados (con polígonos conviene Band edges). La textura de puntos de 16 puede venir de Dot Grid o Pattern. La ampliación de formas propuesta abajo queda implementada. [Prueba manual](tests/composition/DISPLACED-RINGS-MANUAL-QA.md). + +**Idea para ampliar anillos, todavía por elegir:** probar una única familia de formas concéntricas (círculo, triángulo y cuadrado) reutilizando conteo, rotación, desplazamiento y separación. Separar la forma base del corte completo/mitad permitiría extender los semidiscos sin multiplicar controles. No implementar esta ampliación en el prototipo de celdas; presentar la propuesta al usuario primero. + +### 3.4 Reorganizar el catálogo junto con cada incorporación + +Dar protagonismo a los nuevos efectos y menor prominencia a los actuales, manteniéndolos accesibles. «Legacy» es una denominación informal, no una deprecación. El nombre público está abierto; **Essentials** fue una sugerencia no confirmada. Acompañar cada alta posterior con esta organización. + +**Cierre:** las familias seleccionadas ofrecen una inserción visualmente intencional, controles amplios y combinaciones personalizables; superan pruebas de extremos, transparencia y grupos. La selección definitiva queda explícita antes de integrarlas. + +### 3.4 Ideas nuevas compartidas por el usuario (21 de septiembre de 2026) + +**Registradas para planificar; agrupadas el 23 de septiembre de 2026 (ver consolidación abajo); Lumen Print es la primera candidata, pendiente de iniciar.** Se suman al catálogo artístico de la fase 3 y a la revisión de Blob Tracking (6.3–6.5); la investigación de profundidad/parallax (1.5, 8.2) sigue siendo la prioridad que más entusiasma al usuario. + +- **Retrato de puntos conectados** (`21-puntos-conectados-retrato.png`): la imagen se reduce a puntos dispersos coloreados por tono, con enlaces cortos entre vecinos que forman grafos densos en las zonas oscuras y se abren en las claras. Cercano a Particle Grid y a la trama de puntos, pero con conectividad. Decidir si es un pase propio o un modo de Blob Tracking / Particle Grid; medir el coste de la búsqueda de vecinos con video. +- **Frequency modulation** (referencia TouchDesigner, versión propia): modular la señal de la imagen con ondas de frecuencia variable (desplazamiento o color), con controles de frecuencia, fase y portadora; abrir la referencia antes de definir la apariencia. +- **Signal rot** (referencia TouchDesigner, versión propia): degradación progresiva de la señal —arrastre, cuantización, pérdida de sincronía— en la línea de `13-arrastre-escaner-rojo.png` y CRT/Circuit Bent; distinguirlo de los efectos existentes antes de añadir un pase. +- **Lumen print**: apariencia de fotograma solar/cianotipo: virados, halos, bordes quemados y grano orgánico; se conecta con Gradient Map y con `14-rostro-rosa-erosionado.png`. +- **Blob spur**: mejora de Blob Tracking basada en geometría: desplazamiento por pseudo-grafos de datos, elementos de "visión por computadora" (puntos, cajas, trayectorias, etiquetas) sobre las regiones detectadas; comparte dirección con las anotaciones técnicas de 6.3–6.5 y con `19-feedback-anotaciones-tecnicas.png`. + +Para cada una: abrir las referencias, hacer un prototipo pequeño con foto y video, validar visualmente con el usuario y solo entonces integrar al catálogo. Rendimiento con video (7.2) aplica a todas. + +**Consolidación acordada con el usuario (23 de septiembre de 2026):** estas ideas y las familias candidatas pendientes se agrupan en menos capas, diseñadas unificadas desde el inicio (separar un modo después es barato; fusionar capas ya publicadas obliga a migrar proyectos y catálogo). Cada capa unificada ofrece un selector de estilo/modo con valores iniciales propios y controles avanzados plegados; si un modo termina necesitando un conjunto de controles totalmente distinto, se separa durante el prototipo, antes de persistir escenas. + +| Capa | Absorbe | Orden | +| --- | --- | --- | +| **Lumen Print** | Lumen print, Revelado fotográfico alterado (3.2) y Falso color y mapeo tonal quemado (6.1 #1) | 1 — primera entrega | +| **Signal Rot** | Signal rot y Arrastre de escáner (6.1 #4); distinguir de CRT y Circuit Bent existentes | 2 | +| **Dot Grid** | La trama de puntos de 6.1 #2 (afiche celeste, `04-trama-puntos-halos.png`): grilla ortogonal exacta fijada al artboard, un punto en cada celda que crece con el tono, bordes que se disuelven en puntos chicos y una copia difusa debajo; sin ángulos ni CMYK | 3 | +| **Erosion** | La erosión de 6.1 #2 (`09-collage-tipografia-tramas.png`, `14-rostro-rosa-erosionado.png`): la silueta de la foto se desintegra en motas de píxeles con fragmentos dispersos; hacia papel o transparencia | 4 | +| Posteriores | Flares (implementado; Halos de color descartado), Frequency modulation, Retrato de puntos conectados, Relieve grabado (6.1 #5, puede usar profundidad de 6.4) | 5 | + +**Lumen Print implementado (23 de septiembre de 2026); el usuario lo probó en el dev server: "se ve increíble".** Capa de efecto **Lumen Print** (catálogo Core, tras Gradient Map), un solo pase: 16 muestras en espiral áurea con giro por píxel para Diffusion y Halation (Spread en píxeles del documento), cruz de 4 muestras para Edge Lines, Exposure/Contrast, Solarize con Pivot (pliegue alrededor de un tono: Pivot bajo tiende al negativo, alto invierte solo las luces), Washout hacia el extremo papel de la paleta con Ragged Edge, Edge Burn en los bordes de la hoja y Grain/Grain Size en los medios tonos, con Seed; al final la LUT lineal compartida con Gradient Map. Seis estilos que escriben todos los controles: Lumen (por defecto), Cyanotype, Burned (07), Sabattier (12), Washed (06) y Eroded (14); editar cualquier control muestra Custom. Referencias 06, 07, 10, 12 y 14 revisadas con renders de cada estilo sobre fotografía: Burned, Sabattier y Washed se acercan a 07, 12 y 06; Eroded logra la erosión hacia el papel pero queda demasiado binario frente a 14 (conserva poco detalle tonal dentro del rosa). Aceptación final y valores iniciales confirmados por el usuario el 30 de septiembre de 2026. El grano animado para video pasa a una capa **Grain** independiente (ver 6.6); la medición en hardware nativo quedó cubierta en 7.2. [Prueba manual](tests/composition/LUMEN-PRINT-MANUAL-QA.md). + +**Signal Rot implementado (23 de septiembre de 2026), aceptado visualmente por el usuario el 23 de septiembre de 2026 ("se ven increíbles"), que pidió quitar el estilo Tape Wobble.** Capa de efecto **Signal Rot** (catálogo Distort, tras Slice), un solo pase que reubica el muestreo en el marco de las rayas (Streaks: Vertical / Horizontal): bandas rasgadas con bordes irregulares que se desplazan (Tear, Band Size) y caen al color de papel (Dropout, Dropout Color), ondulación lateral (Wobble, Wobble Scale), velocidad de escaneo irregular (Stretch), segmentos retenidos con frente ondulado que arrastran la imagen en rayas largas (Drag, Drag Length), salto de líneas de escaneo y ganancia por línea (Line Noise), desplazamiento de rojo y azul (Chroma Shift) y cuantización de niveles (Crush); Speed 0 congela el daño, mayor a 0 lo hace pudrirse en el tiempo; Seed. Tres estilos: Scanner Drag (por defecto, referencia 13), Signal Rot y Torn Scan. Se distingue de Slice (bloques), CRT (pantalla) y Circuit Bent (líneas curvadas). Referencia 13 revisada con renders de cada estilo: Scanner Drag y Torn Scan logran las rayas retenidas, los frentes ondulados y las roturas blancas; la riqueza de rayas depende de la foto (un cielo liso da rayas planas). Sin realimentación entre frames. [Prueba manual](tests/composition/SIGNAL-ROT-MANUAL-QA.md). + +**Decisión del usuario (23 de septiembre de 2026):** Halftone no se modifica. Es una simulación de impresión (ángulos, CMYK, ganancia de punto) y los puntos buscados son perfectos, casi de grilla; por eso la trama y la erosión son capas nuevas independientes, primero Dot Grid y luego Erosion. + +**Dot Grid implementado y aceptado visualmente por el usuario (23 de septiembre de 2026, "se ve muy bien").** Capa de efecto **Dot Grid** (catálogo Core, tras Halftone): grilla cuadrada exacta centrada en el artboard (Spacing en píxeles del documento), un punto por celda cuyo radio va de Min Dot a Max Dot según el tono leído con cinco muestras alrededor del centro (Softness), con Level (tono donde empieza a crecer) y Contrast; círculo o cuadrado con antialias, tinta de un color o del color de la fuente, fondo y copia difusa debajo (Underlay, Underlay Blur), Invert. Cuatro estilos: Coordinate (por defecto, referencia 04), Source Dots, Night y Pixel Grid. Referencia 04 revisada con renders: la primera versión daba puntos medianos en el cielo claro; Level permite que el papel vacío quede en la mota mínima y los puntos crezcan solo en las formas oscuras, como en 04. Sin ángulos, CMYK ni jitter; los destellos en cruz de 04 quedan para Halos de color. [Prueba manual](tests/composition/DOT-GRID-MANUAL-QA.md). Sigue Erosion. + +**Erosion implementado y aceptado por el usuario (23 de septiembre de 2026, "suficientemente bueno").** Capa de efecto **Erosion** (catálogo Distort, tras Signal Rot), con rol de composición `transform` para poder recortar cobertura: un campo de erosión leído con cinco muestras (Erode From: Edges / Light tones / Dark tones / Cutout edge, el borde de imágenes transparentes o texto), bloques de motas (Speckle Size, Clumping) que se eliminan según Erode, Edge Width, y Scatter que arroja fragmentos a través del borde conservando su color. Reveal: Paper (Paper Color) o Transparent (dentro de un grupo muestra lo que hay debajo). Speed y Seed. Tres estilos: Disintegrate (por defecto, referencia 09), Paper Erosion (14) y Crumbled Cutout (texto). Referencias 09 y 14 revisadas con renders: la primera versión solo abría huecos blancos y detectaba toda la textura; se añadieron fragmentos que sobreviven y se exigieron contornos más fuertes, y Disintegrate ahora desintegra las siluetas en motas con migas de color; Crumbled Cutout desmorona letras sobre una foto. Lumen Print (Washout) sigue siendo la versión suave. [Prueba manual](tests/composition/EROSION-MANUAL-QA.md). Con esto quedan cubiertas las tres capas consolidadas y la separación de 6.1 #2; siguen las posteriores (Halos de color, Frequency modulation, Retrato de puntos conectados, Relieve grabado) o la fase 4. + +**Relieve grabado implementado como Relief (23 de septiembre de 2026), aceptado por el usuario el 23 de septiembre de 2026 ("bastante decente"), que pidió emboss y deboss.** Capa de efecto **Relief** (catálogo Core, tras Dot Grid): altura desde Luminance, Depth (profundidad de escena de 6.4) o Cutout (alfa de texto e imágenes transparentes), **Emboss / Deboss**, Depth, Bevel, luz direccional (Light Angle, Elevation, Ambient), superficie de un color o con los colores de la fuente, Specular y Shininess, Grain, y grabado opcional en líneas paralelas o estallido radial (más profundo en los oscuros). Cinco estilos: Silver Plate (por defecto, referencia 15), Letterpress (deboss sobre papel), Blind Emboss, Gold Foil y Source Relief. Referencia 15 revisada con renders: Silver Plate logra la placa plateada con estallido radial y grano; el centro del estallido producía moiré y ahora se desvanece. Letterpress revisado sobre texto agrupado. [Prueba manual](tests/composition/RELIEF-MANUAL-QA.md). + +**Halos de color descartados; se conserva Flares (23 de septiembre de 2026, decisión del usuario).** Tras probar el prototipo Color Halos, el usuario solo conservó los destellos en cruz (composición propia de una calavera con cruces naranjas en los brillos) y pidió quitar el resto: los halos no combinaban con los destellos y por ahora no les ve uso. La capa pasa a llamarse **Flares** (catálogo Core, tras Lumen Print), con rol `transform`: detección de puntos brillantes aislados a un cuarto de resolución (Threshold, Isolation, Thickness), rayos acumulados por pixel a lo largo de cada dirección con tantas muestras como pide su longitud (Rays 1–16, Rotation, Length, Secondary Rays, Length Jitter, Seed, Falloff), color de rayo y de núcleo, Core Glow y Core Size, compuesto en modo pantalla. Cinco estilos: Cross (por defecto, los valores del usuario), Star, Starburst, Asterisk y Anamorphic. Renders revisados: la primera versión dejaba rayos punteados (pocas muestras) y un error de nombres en los bucles anidados de TSL; ambos corregidos. Halos de color (6.1 #3) queda fuera de V3 salvo que el usuario lo retome. El usuario aceptó Flares el 23 de septiembre de 2026 ("mucho más útil"). [Prueba manual](tests/composition/FLARES-MANUAL-QA.md). + +**Desenfoque y vidrio (23 de septiembre de 2026, decisión del usuario).** El usuario considera "terribles" Directional Blur, Progressive Blur y Fluted Glass (por cómo se ven). Se reemplazan por dos capas nuevas sobre un mismo motor de desenfoque de calidad (pirámide de niveles con muestreo B-spline; radios grandes suaves y tamaño variable por píxel): primero **Blur** (Blur From: Uniform / Depth / Linear / Radial / Luminance; Gaussian, Lens o Motion; grano), con la referencia 31; después **Glass** (Reeded, Hammered, Pyramid, Hex, Frosted; cada celda es una lente, desenfoque según la distancia detrás del vidrio, brillos en los bordes, textura escarchada), con las referencias 26 a 30. Las tres capas viejas salen del menú de agregar pero siguen funcionando en proyectos guardados. **Blur implementado y aceptado por el usuario (23 de septiembre de 2026, "se ve increíble"):** capa **Blur** (catálogo Core, tras Flares), rol `transform`, pirámide de siete niveles con muestreo B-spline; Blur From Uniform / Depth / Linear / Radial / Luminance con Focus, Focus Range, Transition, Center, Band Angle e Invert Focus; Kind Gaussian, Lens (bokeh con Highlights) o Motion; Grain, Grain Size y Grain in Blur. Cinco estilos: Depth of Field (por defecto, referencia 31), Tilt-Shift, Vignette, Soft y Motion. Un estudio sintético de tubos y esferas negras sobre amarillo con mapa de profundidad reproduce el lenguaje de 31. Directional Blur, Progressive Blur y Fluted Glass ya no aparecen en el menú. [Prueba manual](tests/composition/FOCUS-BLUR-MANUAL-QA.md). **Glass implementado y aceptado por el usuario (23 de septiembre de 2026, "me encanta"):** capa **Glass** (catálogo Distort), rol `transform`, sobre la pirámide compartida con Blur: patrones Reeded (perfil redondo o prisma), Hammered, Pyramid, Hex y Frosted; cada celda es una lente (Refraction: poco aumenta, 0.5 enfoca en una línea, 1 invierte en mini imágenes), Distance y Distance From (Uniform o Depth: lo cercano al vidrio queda nítido), bordes finos iluminados, ranuras, escarcha, dispersión y tinte. Seis estilos: Reeded, Frosted Reeded, Hammered, Pyramid, Hex y Frosted. Referencias 26 a 30 revisadas con renders: la primera versión parecía plástico (sombreado de cilindro, escarcha brillante, celdas martilladas como burbujas); se redujo el sombreado a bordes finos y ranuras de ancho fijo, las celdas martilladas pasaron a facetas poco profundas y la escarcha a un rizado sutil. El estudio con profundidad reproduce el helecho de 29. [Prueba manual](tests/composition/GLASS-MANUAL-QA.md). + +**Connected Dots implementado y aceptado por el usuario (23 de septiembre de 2026, "absolutamente perfecto").** Retrato de puntos conectados (referencia 21) como capa **Connected Dots** (catálogo Core, tras Dot Grid), rol `transform`: puntos con jitter por celda que leen tono y color; enlaces con sus 8 vecinos cuyo ancho y probabilidad crecen con la oscuridad. Modos Graph (unión nítida), Blobs (mínimo suave: masas de tinta) y Plexus (líneas finas que se desvanecen). Forma del punto (círculo, cuadrado, cruz, anillo), tamaño por tono, Cutoff para vaciar el fondo, Invert; color por paleta en bandas planas (hasta 8 paradas), de la fuente o tinta; fondo color, imagen o transparente; Drift y Speed para que los enlaces se formen y rompan. Cuatro estilos: Portrait Graph (por defecto, 21), Ink Blobs, Plexus y Constellation. Primeros renders revisados: los enlaces demasiado anchos llenaban las sombras de un bloque sólido; se ajustaron para que queden grafos con huecos como en 21. Segunda etapa (23 de septiembre de 2026, pedida por el usuario): modo **Mesh** (triángulos planos entre los puntos, con Fill, Wire y Wire Color) y seis estilos nuevos: Molecule, Circuit, Neural, Riso, Low Poly y Wireframe; aceptados por el usuario el 30 de septiembre de 2026. Ajustar los puntos a los bordes queda dentro de V3 (ver 6.6). [Prueba manual](tests/composition/CONNECTED-DOTS-MANUAL-QA.md). + +**Plotter rehecho y aceptado por el usuario (24 de septiembre de 2026, "absolutamente increíble"):** modos Hatch, Flow, Contour, Squiggle, Spiral y Stipple sobre la pirámide de desenfoque, con ancho y presión de pluma, temblor, sangrado de tinta, tinta, fuente o tres plumas, papel con grano o transparente, y siete estilos. Cubre las líneas de flujo de la referencia 33 y los contornos y el punteado de la 32. Se conservan los parámetros y valores anteriores. + +**Photocopy implementado y aceptado por el usuario (27 de septiembre de 2026):** tóner aplastado sobre papel con Threshold, Contrast y Generations (copia de copia), relleno desparejo, motas de tóner, rayas del tambor, desregistro, grano y pliegues; color de tóner y de papel o papel transparente. Cinco estilos: Office Copy, Zine, Fax, Blueprint Copy y Pink Flyer. Todo procedural, sin texturas externas. Sigue Outline (contorno festoneado de la referencia 34). + +**Outline implementado y aceptado por el usuario (27 de septiembre de 2026, "increíble"):** contornos con campo de distancia por jump flood alrededor de recortes, texto o formas oscuras/claras; estilos Solid, Double, Dashed y Scalloped (la nube de la referencia 34), Offset (negativo dibuja por dentro), Width, Rings, relleno entre forma y contorno y modo solo línea. Seis estilos: Stroke, Cloud, Sticker, Contour Rings, Cut Line y Double. + +Blob spur queda cubierto en gran parte por Blob Tracking y Annotations (6.3). Diferencia con Gradient Map (2.5.2): Gradient Map solo reasigna color por luminancia; Lumen Print transforma el tono y la superficie (inversión parcial, curvas quemadas, halos, bordes y grano) y puede reutilizar la LUT de color como etapa final. El usuario decidió priorizar estas capas antes de la fase 4 (3D). + +## Fase 4 — Completar el recorrido de modelos 3D + +**Dependencia:** requiere la fase 1. Su posición frente a otras ampliaciones artísticas es revisable. + +### 4.1 Definir compatibilidad e importar modelos + +Definir formatos, materiales y animaciones admitidos y objetivos de rendimiento mediante las pruebas tempranas. Importar modelos con materiales, texturas y recursos necesarios, sin prometer compatibilidad universal. + +### 4.2 Ofrecer una capa 3D útil desde el inicio + +Dar encuadre e iluminación automáticos útiles y controles de posición, rotación, escala, cámara, luces y composición. Combinarla con imagen, video y texto; aplicar efectos gráficos a su imagen renderizada respetando transparencia, grupos y máscaras. + +### 4.3 Integrar animación y persistencia + +Permitir seleccionar clips, reproducir, pausar, ajustar velocidad y repetición. Vincularlos al tiempo de composición y completar importar, editar, guardar, reabrir y exportar. + +### 4.4 Materiales PBR y render con path tracing (28 de septiembre de 2026, pedido por el usuario) + +Pensado para afiches: objetos cromados, vidrio, metal cepillado. + +- **PBR en tiempo real, dentro de la primera entrega de la capa 3D:** los materiales PBR de glTF (metalness, roughness, normal maps, clearcoat, transmisión, emisivo) con el renderer WebGPU; iluminación de entorno HDR con algunos estudios incluidos y carga de `.hdr` propios; reemplazo de material (Chrome, Brushed Metal, Glass, Clay, Rubber, Iridescent); sombras suaves y plano de piso con sombras de contacto. +- **Modo Render con path tracing, entrega aparte:** [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer) (v0.0.25) tiene un backend WebGPU (`three-gpu-pathtracer/webgpu`, `WebGPUPathTracer`), así que se integra sin un segundo contexto WebGL. Requiere three.js 0.185 o superior; desde el paso (2) el repo usa 0.185.1. El path tracing es progresivo (converge en muchos frames): no es para tiempo real ni video; se usa como interruptor **Render** que acumula muestras al pausar o exportar PNG, con denoiser opcional (OIDN) para limpiar antes. La pila de efectos se sigue aplicando encima. +- **Orden acordado (revisado el 29 de septiembre de 2026 a pedido del usuario: el path tracing va al final y three.js se actualiza igual, para usar la última versión):** (1) capa 3D con importación GLB, encuadre e iluminación automáticos, PBR con entorno HDR y reemplazo de materiales, y profundidad exacta del render 3D para Blur, Glass, Relief y máscaras de profundidad; (2) actualización de three.js a la última versión usable (0.185.1; 0.186.1 queda bloqueada por un error de TSL de three) en su propio PR con la suite completa, porque toca todos los sombreadores TSL; (3) clips de animación (4.3); (4) SVG a 3D (fase 5); (5) modo Render con path tracing y denoiser. + +Estado: pasos (1) a (4) implementados el 28 y 29 de septiembre de 2026 y **aceptados por el usuario el 30 de septiembre de 2026** ("the 3d is good to go"). **Paso (5), path tracing, pasa a después de V3 por decisión del usuario (30 de septiembre de 2026):** no forma parte del cierre de V3. + +**Para retomar (29 de septiembre de 2026):** + +- **Aceptado:** los pasos (1) a (4) están en PRs apilados: #189 capa 3D, #190 three.js, #191 animación y #192 SVG a 3D. +- **Cabos sueltos:** resuelto el 29 de septiembre de 2026: la suite completa pasó sobre toda la pila en #192. Antes, la última corrida completa de `bun run test:composition` sobre toda la pila se había cortado a pedido del usuario. La suite completa pasó sobre #190, y las pruebas de animación y de SVG y la prueba de interfaz pasaron por separado. Tampoco se confirmó la CI de los PRs después de regenerar la referencia de sombreadores del MCP. +- **Pasar a three r187 cuando salga:** (1) subir `three` y `@types/three`; (2) subir el peer `three` del runtime en `packages/shader-lab-react/package.json`, con un changeset; (3) correr `bun run --cwd packages/shader-lab-mcp generate`, porque la CI compara esa referencia; (4) quitar `refreshUniformsEveryRender` de `src/renderer/model-pass.ts`, porque r186 ya corrige ese error, y comprobarlo con la prueba de volver a una pose; (5) correr `bun run test:composition`; (6) capturar y comparar el WGSL contra 0.185.1 con `tests/composition/wgsl-capture.mjs` y `wgsl-scan.mjs` (ver `tests/composition/README.md`). Cualquier lectura nueva sin inicializar bloquea la actualización; conviene mirar primero Photographic Cells (el borde con Gap 0) y Focus Blur. Si r187 sigue con el error, quedarse en 0.185.1. +- **Paso (5), path tracing, después de V3:** no depende de lo anterior. `three-gpu-pathtracer` 0.0.25 pide three 0.185 o superior, así que puede empezar sobre 0.185.1. + +**Paso (1), capa 3D Model:** fuente nueva con botón propio en el selector y arrastre de `.glb` al lienzo. Importa GLB y glTF autocontenidos con Draco, meshopt y KTX2 (decodificadores servidos desde `public/three`); un `.gltf` con archivos externos pide un `.glb` y OBJ queda fuera. Encuadre automático por una esfera ajustada a los vértices, con margen; la distancia focal cambia la perspectiva sin cambiar el tamaño. Materiales PBR del archivo o reemplazo por Chrome, Brushed Metal, Glass, Clay, Rubber e Iridescent, cada uno con su color y rugosidad editables. Cinco estudios HDR CC0 de Poly Haven (Studio, Softbox, Warehouse, Sunset, Neon, 1k) o un `.hdr` propio, todos normalizados a la misma irradiancia media para que cambiar de estudio no cambie la exposición; rotación del estudio; luz clave relativa al encuadre con sombras suaves; piso invisible con sombra proyectada y sombra de contacto; exposición y tone mapping (Neutral por defecto). La profundidad exacta del render (blanco = cerca) llega a Blur, Glass, Relief, Input → Depth y máscaras de profundidad, también dentro de grupos. Persistencia en `.lab` (asset de modelo y `environmentAssetId`), autosave, historial, duplicado y exportación PNG/video. + +**UX a pedido del usuario (inspirada en Blender):** panel Transform con Location, Rotation y Scale como campos X/Y/Z (se arrastra la letra del eje para ajustar); gizmos Move, Rotate y Scale en el lienzo con ejes rojo, verde y azul; G, R y S con el cursor sobre el lienzo, X/Y/Z para bloquear un eje, clic o Enter aplica, Esc o clic derecho cancela; botón medio orbita la cámara y Shift + botón medio la desplaza. Cámara plegada por defecto. + +**Límites registrados:** el vidrio refracta el propio modelo y deja ver las capas inferiores sin deformarlas; refractar las capas inferiores es una propuesta para el usuario. La exportación al runtime de React todavía no incluye capas 3D. Clips de animación (4.3), SVG a 3D y modo Render siguen el orden acordado. Se rodearon dos errores de three r183: uniformes de objeto que no se actualizaban al volver a una pose exacta con materiales del archivo, y la copia del fondo para transmisión creada con el formato del canvas durante `compileAsync`; el de la transmisión se retiró en el paso (2) y el de la pose sigue hasta pasar a r186 o posterior. Referencias revisadas: 31, 15 y 26 a 30. [Prueba manual](tests/composition/MODEL-LAYER-MANUAL-QA.md). + +**Paso (2), three.js 0.185.1:** `three` 0.183.2 → 0.185.1 y `@types/three` 0.185.4; el runtime publicado pide `three` ^0.185.0 (un proyecto con 0.183 tiene que actualizar three). Se probó primero 0.186.1, la última versión, y se descartó: trae un error de generación de TSL (three #34339, abierto) por el que un valor intermedio compartido que se lee por primera vez dentro de una rama If/select queda asignado solo ahí y las demás lecturas ven 0. Rompía el borde de Photographic Cells y Focus Blur, y un análisis del WGSL de toda la suite encontró lecturas sin inicializar también en Glass, Connected Dots, Photocopy y Plotter, ninguna en 0.185.1. Un parche que evitaba ese caché dentro de ramas y bucles alargaba demasiado la compilación y no arreglaba Focus Blur. r187 rehace ese caché; cuando salga se prueba de nuevo con la suite completa y el análisis de WGSL. `@types/three` dejó de traer `@webgpu/types` en 0.186, así que ahora es dependencia explícita. En 0.185 se retiró el rodeo de la transmisión (0.183 sigue fallando sin él, 0.185 no) y se mantiene el de la pose exacta, que se corrigió recién en r186 (0.185 sin él falla la prueba de volver a una pose). Siguen el avance del frame de nodos antes de dibujar (ShadowNode actualiza una vez por frame y cámara) y el nodo de entorno persistente. Cambios visibles: la rotación del entorno sigue la convención de three r184 (el mismo sentido que rotar un objeto), así que Studio Rotation gira al revés que en 0.183, sin migración porque la capa 3D no se publicó. El cambio de alfa premultiplicado de r185 solo afecta píxeles transparentes en la salida y el lienzo es opaco; las 100 pruebas de alfa pasan. three ahora informa los errores de GPU no capturados: la cadena de errores al importar cuadros de video en SwiftShader (los mismos 64 errores ocurren en 0.183, antes silenciosos) queda exceptuada en la suite de composición, que falla ante cualquier otro error. + +**Paso (3), clips de animación (4.3):** la capa 3D Model lee los clips del `.glb` (nombre y duración salen del JSON del glTF, así el panel los lista sin cargar el modelo). Clip elige uno, All clips los reproduce todos o None deja el modelo en reposo; el valor automático reproduce todos cuando cada clip mueve objetos distintos (acciones por objeto exportadas desde Blender) y si no el primero (Idle, Walk, Run sobre un mismo esqueleto). Play, Speed (0 a 4×), Repeat (Loop, Ping-Pong, Once) y Start: el tiempo del clip sale del tiempo de composición (Start + tiempo × Speed), así que la vista previa, el PNG y el video coinciden cuadro a cuadro; en pausa se ve el cuadro Start. Importar un modelo animado ajusta la duración de la línea de tiempo al clip, como el video (gana la última importación). El encuadre, el piso y las sombras se calculan sobre 24 poses de todo el clip, así que el modelo no sale del cuadro ni atraviesa el piso mientras se mueve. La profundidad exacta, las sombras y los materiales de reemplazo siguen las mallas con esqueleto y los morph targets; el ruido de Brushed Metal e Iridescent se ancla a la pose de reposo para no deslizarse sobre la superficie. Los ajustes se guardan en el `.lab`. + +**Paso (4), SVG a 3D (fase 5):** en el selector, 3D Model también acepta `.svg`; el logo se convierte en un sólido y la capa sigue siendo una capa 3D normal (transformación Blender, materiales, estudios, sombras, profundidad exacta). Ver 5.1 y 5.2. + +**Cierre:** materiales, texturas y animaciones compatibles sobreviven al recorrido completo; vista previa y exportación coinciden temporalmente y el modelo respeta el alcance de grupos y máscaras. + +## Fase 5 — Convertir SVG en volumen editable + +**Dependencia:** reutiliza el recorrido de capas 3D de la fase 4. + +### 5.1 Definir el subconjunto SVG compatible + +Precisar soporte de trazos, huecos, trazados compuestos y casos excluidos a partir de pruebas con logotipos. + +### 5.2 Generar y editar el volumen + +Derivar geometría de los contornos y ofrecer profundidad de extrusión, bisel, material, orientación e iluminación. El resultado funciona como una capa 3D normal; no implica reconstrucción arbitraria de modelos complejos mediante IA. + +**Cierre:** los SVG admitidos generan capas utilizables que se editan, guardan, reabren y exportan; compatibilidad y exclusiones quedan claras. + +Estado: implementado el 29 de septiembre de 2026 como paso (4) de 4.4 y aceptado por el usuario el 30 de septiembre de 2026 junto con la capa 3D. **Subconjunto admitido:** trazados (`path` con todos sus comandos), `rect`, `circle`, `ellipse`, `polygon`, `polyline` y `line`, grupos con transformaciones, colores de relleno y `fill-rule` (nonzero y evenodd, así los huecos y trazados compuestos quedan abiertos), y trazos con su grosor, uniones y extremos. **Excluidos:** texto (hay que convertirlo a contornos; un SVG solo con texto muestra ese aviso), imágenes, filtros, máscaras, recortes y patrones; los degradados toman un color sólido. **Volumen:** Depth es una fracción del lado mayor del logo; Bevel redondea el frente y el dorso sin agrandar el contorno; Bevel Smoothness da los pasos del bisel; los trazos se extruyen rectos. Cada trazado queda apenas delante del anterior para que las superposiciones no parpadeen; las caras curvas se sombrean lisas y las esquinas quedan vivas. Material Original usa los colores del SVG; los reemplazos (Chrome, Glass, etc.), la orientación (Rotation, gizmos) y la iluminación son los de la capa 3D. Se guarda en el `.lab` como asset de modelo con tipo `image/svg+xml`. Soltar un SVG en el lienzo sigue creando una capa de imagen. + +## Fase 6 — Ampliar las familias artísticas seleccionadas + +**Prioridad propuesta y revisable:** comenzar por tono y textura para enriquecer combinaciones. Este orden no es una dependencia técnica ni una aprobación de las cinco familias. + +### 6.1 Evaluar las candidatas restantes en este orden propuesto + +| Orden | Familia | Resultado y referencias | +| --- | --- | --- | +| 1 | Falso color y mapeo tonal quemado — **dentro de Lumen Print (3.4)** | Recoloración selectiva y pérdida de detalle; rana oscura con amarillo, naranja y azul, y fotografía industrial negra y naranja. | +| 2 | Tramas, grano y erosión | Puntos, desintegración y vacíos; afiche celeste punteado, collage cromático denso y rostro u ojos rosados que desaparecen en una página casi vacía. | +| 3 | Halos de color | Bandas difusas alrededor de figuras o texto; estrella con zonas azules, amarillas y rojas. | +| 4 | Arrastre de escáner — **dentro de Signal Rot (3.4)** | Estiramiento, compresión y desplazamiento irregular de bandas; afiche rojo distorsionado. | +| 5 | Relieve grabado | Volumen superficial aparente mediante luces y sombras; composición gris y plateada. | + +### 6.2 Integrar la selección y ampliar combinaciones + +Elegir según resultados visuales y viabilidad, aplicar los criterios de controles y valores iniciales, actualizar el catálogo en cada incorporación y ampliar las combinaciones editables. + +### 6.3 Anotaciones técnicas y calidad de Blob Tracking — dirección artística destacada + +**Prioridad revisada:** el usuario quiere profundizar en estos detalles; deja de ser una nota de prioridad baja. Los puntos naranjas, cajas rojas, cruces, etiquetas y líneas de las nuevas referencias aportan una dirección visual importante para V3. Desarrollarla después de las herramientas de composición prioritarias, por entregas con revisión visual. + +Revisar primero lo que ya ofrece Blob Tracking: detección, estabilidad temporal, contornos, valores iniciales, controles y coste con fotografía/video. Decidir qué conviene ampliar allí y qué merece una capa de anotaciones independiente, sin duplicar funciones ni asumir una reescritura. + +Explorar colocación guiada por bordes/regiones y colocación decorativa con semilla. Buscar combinaciones de puntos, cajas, cruces, etiquetas y conexiones con control coherente de densidad, escala y color, evitando una lista inmanejable de parámetros. Las etiquetas decorativas deben distinguirse de una detección real: una caja con texto no demuestra reconocimiento de personas ni seguimiento semántico. Revisar renders con las referencias abiertas, tanto en composiciones sutiles como densas. + +En video, evitar parpadeo y saltos de identificadores/posiciones cuando sea viable; medir estabilidad y carga sostenida antes de prometer seguimiento. Conservar la apariencia de escenas anteriores, historial, persistencia y paridad de exportación. Los modos de máscara derivados de detección siguen como extensión posterior a las cuatro máscaras manuales de 1.4; distinguir visualización, efecto localizado y recorte de cobertura. + +**Decisiones del usuario (22 de septiembre de 2026), tras revisar las referencias 19, 03, 10 y 21:** + +- Dos entregas: **parte 1**, revisión y mejora de Blob Tracking (etiquetas tipo `PERSON 01XX` con IDs por semilla, esquinas/corner brackets como estilo de marco, nube de puntos sobre el borde de cada blob, estabilidad de IDs/cajas en video, medición con el benchmark de video). **Parte 2**, capa **Annotations** independiente y decorativa, solo cuando el usuario confirme la parte 1. +- Annotations combina **colocación guiada por bordes** (campo de bordes/luminancia reducido de las capas inferiores) **y** aleatoria con semilla; ambas requeridas. +- Etiquetas: un vocabulario preset (PERSON, TARGET, NOT FOUND, coordenadas, frecuencias) **y** un modo custom con lista de etiquetas del usuario. Sin detección real: la "detección" se simula con las etiquetas del usuario y la interfaz lo dice. +- Color: monocromo por capa **y** por familia de elementos; reutilizar el editor de rampa/parada de Gradient Map para afinar. +- Tipografía: fuentes de la capa Text (Geist Mono), tamaños pequeños ahora que el mínimo es 8. +- Video: las anotaciones derivan con semilla en el tiempo, como Blob Tracking (cuyo rendimiento el usuario considera bueno). +- Puntos conectados (ref. 21) como modo posterior dentro de Annotations o Particle Grid, tras medir la búsqueda de vecinos en video. + +**Parte 1 implementada el 22 de septiembre de 2026 y confirmada por el usuario (ver cierre de la parte 1):** el tracker conserva hasta 64 celdas de borde por blob (grilla de análisis 64×36) y las expone como `edge`; el pase añade **Frame** (Outline / Corner brackets / None, con Bracket Length), **Edge Dots** (densidad y tamaño, muestreo por semilla, mismo adelanto por velocidad que las cajas) y **Label Text** (Coordinates / Prefix + ID / Custom list, con Label Seed): los códigos y las palabras se asignan por hash determinista del id de la pista, así no parpadean entre frames. El atlas de etiquetas incorpora letras y puntuación (20 caracteres por etiqueta). `showOutline` queda oculto y migra a `frameStyle` al abrir archivos antiguos. Editor y runtime idénticos salvo imports; escenario `--blob` en el benchmark de video. Referencia 19 revisada. [Prueba manual](tests/composition/BLOB-TRACKING-MANUAL-QA.md). Tras la primera prueba del usuario con video (22 de septiembre): detección de movimiento mejorada —diferencia dilatada en radio 2, grilla de análisis 128×72 (antes 64×36; el ancho debe mantener filas de 256 bytes para la lectura asíncrona), histéresis por celda y fallback a luminancia solo con menos de dos celdas en movimiento—, medida con `tests/composition/blob-motion-probe.mjs` sobre aura.mp4 (área media de blob 0,3 % → 2,4 % de la grilla, celdas en movimiento 1,2 % → 8,8 %, IDs 14 → 11 en 85 frames); nuevo **Lock 1:1** para cuadrados/círculos (activo por defecto en capas nuevas; las escenas guardadas conservan aspecto libre); Trail Decay por defecto 0. Pendiente para la parte 2: colores por elemento, capa Annotations. + +**Cierre de la parte 1 (22 de septiembre, tras el segundo test con video):** el usuario confirma que la detección "funciona mucho mejor" y considera que Blob Tracking no admite mucha más mejora; la parte 2 pasa a ser la prioridad. Dos aportes del usuario para la parte 2: (a) reabre `03-rana-falso-color.png` como referencia principal de la capa Annotations: círculos concéntricos y anillos punteados alrededor de un punto de interés con retículo, cruces pequeñas, reglas con marcas y contador (`134`), etiquetas de estado (`NOT FOUND`), lecturas de coordenadas/ángulo (`-{0}2223°`, `14.3`), bloques de metadatos tipográficos en las esquinas (`START 1.000 MHz / STOP 403.000 MHz`, `OP-ID`, `LOC: [REDACTED]`), una barra de degradado, letras aisladas y grietas/ruido orgánico; todo en un solo color tenue sobre falso color; (b) **pincel para decidir dónde se dibujan los elementos**: reutilizar la cobertura pintada de 1.4 como máscara de colocación (además de bordes y semilla), de modo que los elementos sólo aparezcan dentro de la pintura. + +**Decisiones finales para la parte 2 (22 de septiembre):** el punto de interés del objetivo se coloca con una manija arrastrable **y** puede ajustarse automáticamente al borde más fuerte; los textos parten de presets de instrumento **editables** línea a línea; el usuario pide especialmente elementos **punteados/discontinuos** (anillos y cajas dashed, reglas, conectores). Colocación: aleatoria con semilla, guiada por bordes y **pintada con pincel**. Colores: mono o paleta por elemento con el editor de rampa. + +**Parte 2 implementada y aceptada visualmente por el usuario (22 de septiembre de 2026, con dos composiciones propias: referencias 22 y 23):** capa de efecto **Annotations** (catálogo Core). Trazado determinista en CPU (`renderer/annotations-layout.ts`, compartido por editor y runtime) → hasta 320 marcas y 1024 glifos dibujados como quads instanciados con SDFs (punto, anillo, anillo punteado, retículo, cruz, caja, caja punteada, esquinas, línea, línea punteada, regla con ticks, barra de degradado) y el atlas mono de Blob Tracking para el texto. Colocación **Seeded / Edges / Painted**: bordes por Sobel 128×72 leído cada tres frames; pintada con el mismo pincel y códice `pc1` de máscaras y Cells (target `annotations` del store de pintura). Objetivo con anillos concéntricos sólidos y punteados, retículo, lecturas de ángulo y valor, conectores punteados; manija arrastrable (centro + tamaño) y **Snap to Strongest Edge**. Familias activables (dots, rings, crosses, boxes, connectors, labels, rulers, metadata), Density, Seed, Drift (movimiento con semilla en el tiempo), Scale, Stroke Width, Text Size. Texto: presets Instrument / Surveillance / Scientific / Minimal que rellenan **Words** y **Metadata**, ambos editables. Color mono o **Palette** (rampa; cada elemento elige una parada por semilla). Referencias 03, 10, 19 revisadas; nada se reconoce, y la interfaz lo dice. [Prueba manual](tests/composition/ANNOTATIONS-MANUAL-QA.md). Puntos conectados, colocación por regiones y rotación de elementos implementados el 30 de septiembre de 2026 (ver 6.6). + +### 6.4 Profundidad y parallax — investigación aprobada + +Investigar primero **imagen + depth map importado** para movimiento sutil de cámara/parallax, desplazamiento y aplicación selectiva de efectos según distancia. Parallax es un resultado que puede usar profundidad; no requiere prometer otra clase de mapa al usuario. Definir alineación, inversión/escala de profundidad y tratamiento de bordes/huecos antes de elegir controles o incorporar el efecto. + +Usar [DepthFlow](https://github.com/BrokenSource/DepthFlow) como referencia técnica para la exploración, sin asumir que se integra directamente en el navegador. Revisar arquitectura, dependencias y condiciones de reutilización antes de elegir una integración. Comparar una prueba pequeña con los objetivos de rendimiento de Shader Lab. + +La generación automática de profundidad viene después: evaluar ejecución local o servicio, coste, latencia y reutilización del resultado. Comenzar con fotografía; profundidad de video, estabilidad entre frames y relación con futuras escenas 3D requieren validación separada. No ejecutar inferencia por frame por defecto ni asumir compatibilidad solo porque una biblioteca la ofrezca. La investigación debe producir una decisión de viabilidad y alcance; no constituye una promesa de entregar todas estas variantes en V3. + +**Diseño acordado (23 de septiembre de 2026):** la profundidad vive **en la capa Image**, no en una capa de efecto aparte. El usuario adjunta un segundo archivo (mapa de profundidad en escala de grises) a la misma capa; la capa conserva su asset de color y suma `depthAssetId`. El razonamiento del usuario: la profundidad interesa sobre todo **combinada con otros efectos**, así que debe ser un dato de la fuente que el resto de la pila pueda leer, no un efecto cerrado. + +Etapa 1 (esta entrega, prototipo de viabilidad): parallax con oclusión en la propia capa Image. El sombreador marcha por capas de profundidad desde lo cercano hacia lo lejano (parallax occlusion mapping, refinado binario), como el bucle de dos fases de DepthFlow, sin depender de esa biblioteca. Controles del grupo **Depth**, visibles solo con un mapa adjunto: Invert Depth (por defecto blanco = cerca), Depth Range (separación entre planos), Focus Plane (profundidad que permanece quieta), Motion (Off / Orbit / Sway / Nod / Dolly), Amount, Speed, Offset (vec2 animable por timeline para una cámara propia), Edges (Stretch: los bordes se estiran; Transparent: alfa 0 fuera del cuadro), Quality (pasos de marcha) y Show Depth (muestra la profundidad ya desplazada en lugar del color). Alineación: el mapa debe compartir encuadre con la foto; se muestrea con las mismas UV y no se realinea. Huecos de desoclusión: se estiran los píxeles vecinos, como hace DepthFlow sin inpainting; no se inventa contenido. El camino sin profundidad no cambia de sombreador ni de coste. + +Etapa 2, **profundidad de escena (aprobada por el usuario tras mostrar una composición donde duplicaba la imagen con Show Depth para alimentar ASCII; implementada y aceptada el 23 de septiembre de 2026; el usuario confirma que el frame rate se mantiene con profundidad activa):** la capa Image con mapa resuelve la marcha una sola vez en un destino auxiliar (RGB = profundidad desplazada, blanco = cerca; A = dentro del cuadro) y la pipeline lo lleva hacia arriba como profundidad de escena, también dentro y fuera de grupos; gana la fuente con profundidad más cercana por debajo, y las fuentes sin mapa la dejan pasar. Dos consumidores: (1) **Input: Luminance / Depth** en ASCII, Halftone, Pattern y Gradient Map: el efecto muestrea la profundidad como si fuera la imagen, pero compone sobre el color; sin mapa debajo vuelve a luminancia. (2) Forma de máscara **Depth** con Near / Far / Feather para limitar cualquier efecto por distancia; sin mapa la máscara no hace nada. Coste cero sin profundidad: mismo sombreador, solo cambia qué textura se enlaza, y el cambio de fuente es estructural. Medición en el arnés (SwiftShader, 1080p, mediana por frame): {"plain":32.3,"depthMedium":714.8,"depthHigh":1214.1,"depthLow":433.6} ms. **En hardware nativo (13 de octubre de 2026, GPU Apple Metal 3, mismo `checkDepthParallax` sin SwiftShader, mediana de tres corridas):** sin profundidad 0,8 ms, Low 1,6 ms, Medium 3,0 ms y High 4,0 ms por frame a 1080p; la marcha suma de 0,8 a 3,2 ms, holgado para 60 fps. Detalle técnico: TSL comparte enlaces de textura por uuid, así que el cambio de fuente recompila el nodo de efecto cuando aparece o desaparece la profundidad. Profundidad en video sigue como decisión separada. + +**Máscaras animables (23 de septiembre de 2026, pedidas por el usuario para el barrido de contornos):** Center, Size (Near / Far en la forma Depth), Rotation y Feather de la máscara se exponen al timeline como pistas de parámetro con clave `mask.*`, con botón de keyframe junto a cada control, valores evaluados en editor y runtime, y enlaces de audio. Near y Far son pistas independientes (**Mask Near**, **Mask Far**), por pedido del usuario. El barrido: dos keyframes en cada una y la franja térmica recorre el sujeto de delante a atrás. + +Prueba: pares sintéticos color + profundidad generados con Three en el arnés (el usuario no dispone de fotos con mapa), paridad editor/runtime/PNG, hidratación de proyectos anteriores sin cambios de apariencia, y una medición del coste del marcheo a 1080p. La decisión de viabilidad se registra al cierre de la etapa 1. + +**Etapa 1 implementada y aceptada por el usuario (23 de septiembre de 2026, con las referencias 24 y 25):** `depthAssetId` en la capa (archivo .lab, historial, duplicado, exportación como `depthAsset`, MCP), sección **Source → Depth map** con Attach / Replace / Remove, grupo **Depth** visible solo con mapa adjunto, marcha en `renderer/media-pass.ts` (editor y runtime) dentro de una `Fn` de TSL con refinamiento binario de cinco pasos; Low / Medium / High = 16 / 32 / 64 capas. El camino sin mapa conserva el sombreador anterior. Comportamiento verificado con pares sintéticos: el borde delantero se desplaza exactamente shift × range × depth; el borde trasero de un sujeto cercano se estira sobre el hueco (el mismo artefacto que DepthFlow sin inpainting; los mapas con bordes suaves lo disimulan). Sin fotos con profundidad del usuario todavía: la aceptación visual necesita una foto real con su mapa. [Prueba manual](tests/composition/DEPTH-PARALLAX-MANUAL-QA.md). Pendiente: profundidad en video. El coste a 1080p en hardware nativo quedó medido el 13 de octubre de 2026 (ver etapa 2) y la etapa 2 está implementada. + +**Generación en la app (23 de septiembre de 2026, pedida por el usuario: no quiere adjuntar mapas, quiere generarlos):** botón **Estimate** junto a Attach. Ejecuta Depth Anything V2 Small (el mismo modelo que usa DepthFlow) en un Web Worker con transformers.js: WebGPU en fp32 cuando hay adaptador, WASM en q8 si no; descarga del modelo desde Hugging Face (99 MB o 27 MB) con caché del navegador y progreso visible; salida en escala de grises con blanco = cerca, reescalada al tamaño de la foto y adjuntada como asset local `nombre-depth.png` (Invert Depth queda en off). Sin inferencia por frame ni video. En Chromium headless con SwiftShader: 10 s de descarga y unos 90 s de inferencia sobre flora.webp; en hardware nativo se espera segundos. La aceptación visual ya puede hacerse con cualquier foto del usuario. + +### 6.5 Mejorar Pattern con patrones propios — prioridad baja + +**Exploración solicitada:** ampliar la capa Pattern existente para cargar SVG o imágenes propias y organizar una secuencia de motivos. Permitir previsualizar y reordenar los elementos que se asignan de zonas claras a oscuras. El orden manual debe expresar una intención visual, sin depender de ordenar automáticamente por el brillo de los archivos. + +Ejemplo del usuario: una secuencia desde una manzana verde/fresca hasta una podrida, asignada por orden a tonos claros → oscuros. Cambiar el orden cambia qué motivo aparece en cada rango tonal de la imagen; no significa interpolar o transformar una manzana en otra. + +Permitir conservar los colores originales de las imágenes y SVG multicolor. Explorar un modo explícito de recoloración para SVG compatibles, definiendo si reemplaza un color, usa una tinta o permite editar una paleta; no destruir los colores originales por defecto ni prometer recolorear cualquier SVG arbitrario. Definir formatos/subconjunto SVG, transparencia, escala, separación, cantidad de motivos y comportamiento de los rangos al ampliar la herramienta. + +Conservar patrones incorporados y proyectos anteriores. Los motivos cargados, su orden y sus colores deben sobrevivir a duplicación, historial, guardado/reapertura y exportación editor/runtime. Validar con fotografía y video, reutilizando texturas/atlas sin decodificar archivos por frame; medir memoria y fijar límites útiles al implementar. Esta exploración de prioridad baja no bloquea las entregas prioritarias; su incorporación final a V3 queda por decidir. + +**Implementado y aceptado por el usuario el 30 de septiembre de 2026 ("great fucking job", con su retrato de bananas: referencia 36; retomado a pedido del usuario tras aceptar la capa 3D; boceto de la bandeja en la referencia 35).** Preset → **Custom** en la capa Pattern muestra la bandeja **Motifs** justo debajo del selector: hasta 10 imágenes o SVG (límite pensado para no superar los 12 recursos de una escena publicada en la comunidad), cargados de a varios con el botón **+** o soltándolos sobre la bandeja. Se reordenan arrastrando: la baldosa se levanta, se inclina y sigue al puntero mientras las demás se corren; al soltar queda un solo paso de deshacer; Esc cancela. Con el teclado, Alt + flechas mueve y Suprimir quita; cada baldosa tiene además una × para quitarla. **Orden:** de izquierda a derecha, de tonos claros a oscuros (la manzana fresca primero, la podrida al final); cada motivo cubre una banda igual del tono perceptual de la imagen e Invert invierte el orden. **Color:** el nuevo modo **Motif colors** dibuja cada motivo con sus colores originales sobre el fondo (Background mezcla la imagen de abajo) y se elige solo al cargar los primeros motivos si el modo seguía en Source; Source, Quantized, Monochrome y Custom siguen disponibles y usan el alfa del motivo como forma, así que tintan su silueta (tinta explícita; no se reemplazan colores dentro del SVG). Sin motivos, la capa deja pasar la imagen. Los presets Bars, Candles y Shapes no cambian. **Persistencia:** `patternAssetIds` en la capa (orden = arreglo) entra en guardado `.lab`, autosave, historial, duplicado y publicación; un motivo que falta al reabrir se quita de la lista con el aviso `Missing motif: `, y uno que no carga se informa sin perder los demás. **Exportación:** `patternAssets` en el runtime (`preset: "custom"`, `colorMode: "original"`, rutas `/replace/image/...` distintas por motivo). El atlas se arma una sola vez por cambio de motivos o de Cell Size, con las imágenes decodificadas en caché; no hay trabajo por frame. [Prueba manual](tests/composition/PATTERN-MOTIFS-MANUAL-QA.md). **Recoloración de SVG (13 de octubre de 2026):** con Color Mode en Motif colors, la sección de motivos lista los colores de los SVG cargados (File colors, el mismo control que las formas SVG) y cada uno se reemplaza; la paleta se guarda en `motifPalette` y se aplica en editor y runtime al armar el atlas. Las imágenes y los proyectos sin paleta no cambian. **Pendiente:** probar sobre video; el atlas es fijo, así que el costo por frame es el mismo que con los presets. + +**Cierre:** las familias seleccionadas pasan sus pruebas visuales, de libertad y composición; las candidatas pospuestas quedan identificadas sin presentarlas como entregas prometidas. Registrar el resultado de anotaciones/Blob Tracking y la decisión de viabilidad de profundidad/parallax. Identificar explícitamente qué alcance de Patterns se incorpora o se aplaza. + +### 6.6 Últimas entregas de V3 (30 de septiembre de 2026, acordadas con el usuario) + +El usuario da por cerradas 7.1 y 7.2 y elige, de los pendientes que quedaban, estas entregas para cerrar V3, en este orden y cada una en su PR apilado: **Las cinco quedaron aceptadas por el usuario el 13 de octubre de 2026, con lo que se completa el alcance obligatorio de V3.** + +1. **Formas SVG en la capa Shape (2.7), imprescindible:** una forma a partir de un SVG subido, con bordes nítidos a cualquier tamaño, Softness y Outline como las formas procedurales, y colores editables donde el archivo lo permita. **Implementado el 30 de septiembre de 2026 y aceptado por el usuario el 13 de octubre de 2026.** Shape → **SVG** muestra **Choose SVG / Replace** debajo del selector; al elegir un archivo, la forma toma sus proporciones. Se decidió distancia firmada frente a rasterizar como la capa Image: el SVG se rasteriza una vez (1024 px en el lado largo, margen de 48 px) y una transformada de distancia euclidiana exacta, refinada con el alfa antialias del borde, da un campo de distancia en media precisión y una textura de color dilatada hacia afuera. El mismo sombreador de formas lo muestrea (cambiar de forma sigue sin recompilar), así que el borde queda nítido a cualquier tamaño y Softness y Outline valen lo mismo que en las formas procedurales. **SVG Colors → Original** conserva los colores del archivo y lista cada uno en **File colors** para cambiarlo (atributos, estilos, reglas ``, + { headers: { "Content-Type": "text/html" } } + ) + if (path === "/browser.js") + return new Response(bundle.outputs[0], { + headers: { "Content-Type": "text/javascript" }, + }) + if (path === "/scenes/default/alpha-sample.svg") + return new Response( + Bun.file(resolve(directory, "fixtures/alpha-sample.svg")) + ) + if (path === "/scenes/default/dof-study.png" || path === "/scenes/default/dof-study-depth.png") + return new Response( + Bun.file(resolve(directory, `fixtures/${path.split("/").pop()}`)) + ) + if (path === "/scenes/default/rings-photo.webp") + return new Response(Bun.file(resolve(root, "public/examples/effects/slice.webp"))) + if (/^\/scenes\/default\/(motif|shape)-[a-z-]+\.svg$/.test(path)) + return new Response( + Bun.file(resolve(directory, `fixtures/${path.split("/").pop()}`)) + ) + const base = + path.startsWith("/fixtures/") || path.startsWith("/baselines/") + ? directory + : resolve(root, "public") + const file = resolve(base, `.${path}`) + if (!(file.startsWith(`${base}${sep}`) && (await Bun.file(file).exists()))) + return new Response("Not found", { status: 404 }) + return new Response(Bun.file(file)) + }, +}) + +let browser +try { + browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], + }) + const page = await browser.newPage() + page.setDefaultTimeout(120_000) + const errors = [] + const videoImportError = + /^THREE\.WebGPURenderer: Uncaptured WebGPU GPUValidationError: [^\n]*\n - While (?:validating \[ExternalTextureDescriptor\]|validating CopyExternalTextureForBrowser)/ + const videoImportView = + /^THREE\.WebGPURenderer: Uncaptured WebGPU GPUValidationError: \[Invalid Texture\] is invalid due to a previous error\.\n - While calling \[Invalid Texture\]\.CreateView/ + const unexpectedErrors = () => { + let views = errors.filter((text) => videoImportError.test(text)).length + return errors.filter((text) => { + if (videoImportError.test(text)) return false + if (views > 0 && videoImportView.test(text)) { + views -= 1 + return false + } + return true + }) + } + page.on("pageerror", (error) => { + errors.push(error.message) + console.error(error.message) + }) + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()) + if (!(videoImportError.test(message.text()) || videoImportView.test(message.text()))) + console.error(message.text()) + } + }) + page.on("requestfailed", (request) => { + if ( + request.resourceType() === "media" && + request.failure()?.errorText === "net::ERR_ABORTED" + ) + return + const message = `${request.url()}: ${request.failure()?.errorText}` + errors.push(message) + console.error(message) + }) + await page.goto(server.url.href) + await page.waitForFunction(() => typeof window.checkProject === "function") + await page.evaluate(() => document.fonts.load('400 96px "Fixture Mono"')) + assert.ok( + await page.evaluate(async () => !!(await navigator.gpu?.requestAdapter())), + "WebGPU is required; no fallback or skipped tests" + ) + console.log( + "GPU adapter:", + await page.evaluate(async () => { + const adapter = await navigator.gpu.requestAdapter() + return adapter + ? { + vendor: adapter.info.vendor, + architecture: adapter.info.architecture, + device: adapter.info.device, + description: adapter.info.description, + } + : null + }) + ) + + console.log( + "Clean projects:", + await page.evaluate(() => window.checkCleanProjects()) + ) + + console.log( + "Existing project:", + await page.evaluate(() => window.checkExistingProject()) + ) + const blends = await page.evaluate(() => window.checkLegacyBlendContract()) + const blendPath = resolve(directory, "baselines/legacy-blends.json") + for (const sample of blends) { + assert.ok( + sample.rgba.every(Number.isFinite), + `Non-finite pixel: ${JSON.stringify(sample)}` + ) + assert.equal(sample.rgba[3], 1, "Legacy compositor is opaque") + } + const half = blends.length / 2 + for (let i = 0; i < half; i++) { + assert.deepEqual( + blends[i].rgba, + blends[i + half].rgba, + "Editor/runtime blend mismatch" + ) + } + if (update) { + await Bun.write(blendPath, `${JSON.stringify(blends, null, 2)}\n`) + } else { + const expected = await Bun.file(blendPath).json() + assert.equal(blends.length, expected.length) + for (const [index, sample] of blends.entries()) { + const { rgba, ...identity } = sample + const { rgba: expectedRgba, ...expectedIdentity } = expected[index] + assert.deepEqual(identity, expectedIdentity) + for (let channel = 0; channel < 4; channel++) { + assert.ok( + Math.abs(rgba[channel] - expectedRgba[channel]) < 0.00001, + `Legacy blend changed: ${JSON.stringify(sample)}` + ) + } + } + } + console.log(`PASS ${blends.length} editor/runtime blend and mask samples`) + for (const name of [ + "legacy-text-mask", + "solid-text-background", + "transparent-text-screen", + "contained-image-halftone", + ]) { + const result = await page.evaluate( + ({ name, update }) => window.checkProject(name, update), + { name, update } + ) + const output = update + ? resolve(directory, `baselines/${name}.png`) + : resolve(artifacts, `${name}.png`) + await Bun.write(output, Buffer.from(result.png.split(",")[1], "base64")) + assert.equal( + result.baselineDiff?.changedPixels ?? 0, + 0, + `${name}: ${JSON.stringify(result.baselineDiff)}; actual image: ${output}` + ) + console.log( + `PASS ${name}: preview/export and reopen match; ${result.colors} colors` + ) + } + const mediaBounds = await page.evaluate(() => window.checkMediaBounds()) + await Bun.write( + resolve(artifacts, "transparent-media-bounds.png"), + Buffer.from(mediaBounds.transparentPng.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "solid-media-bounds.png"), + Buffer.from(mediaBounds.solidPng.split(",")[1], "base64") + ) + console.log( + `PASS media bounds: ${mediaBounds.samples} editor/runtime image/video samples, migration, save/reopen, shader export, and PNG export` + ) + const alphaSamples = await page.evaluate(() => window.checkAlphaCompositing()) + console.log( + `PASS ${alphaSamples} alpha samples: source-over, effects, pass materials, grading, and the runtime texture pipeline` + ) + const groups = await page.evaluate(() => window.checkGroups()) + await Bun.write( + resolve(artifacts, "isolated-groups.png"), + Buffer.from(groups.png.split(",")[1], "base64") + ) + console.log( + `PASS ${groups.samples} group checks: isolation, nesting, opacity, ordering, lifecycle, and editor PNG export` + ) + assert.deepEqual(unexpectedErrors(), [], "Browser or GPU errors occurred") + const editorGroups = await page.evaluate(() => window.checkEditorGroups()) + await Bun.write( + resolve(artifacts, "saved-editor-group.png"), + Buffer.from(editorGroups.png.split(",")[1], "base64") + ) + console.log( + `PASS editor groups: store operations, history, v7 hydration, ${editorGroups.tracks} animation tracks, shader export, and saved/reopened pixels` + ) + const text = await page.evaluate(() => window.checkTransparentText()) + await Bun.write( + resolve(artifacts, "new-transparent-text.png"), + Buffer.from(text.png.split(",")[1], "base64") + ) + console.log( + `PASS transparent text: ${text.samples} editor/runtime pixel checks, legacy hydration, save/reopen, and PNG export` + ) + const rings = await page.evaluate(() => window.checkDisplacedRings()) + for (const [label, png] of Object.entries(rings.studies)) + await Bun.write( + resolve(artifacts, `rings-study-${label}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "displaced-rings-preview.webp"), + Buffer.from(rings.previewWebp.split(",")[1], "base64") + ) + for (const [name, png] of [ + ["ring-cutout", rings.png], + ["displaced-rings-preview", rings.previewPng], + ]) { + await Bun.write( + resolve(artifacts, `${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + } + console.log( + `PASS displaced rings: ${rings.samples} GPU cases, 48 distinct bands (${rings.denseMs}ms including readback), hydration, runtime export, group cutouts, preview/PNG` + ) + const threshold = await page.evaluate(() => window.checkThresholdColors()) + await Bun.write( + resolve(artifacts, "threshold-colors.png"), + Buffer.from(threshold.png.split(",")[1], "base64") + ) + console.log( + `PASS threshold colors: ${threshold.samples} editor/runtime GPU cases, decoded video, alpha, history, hydration and PNG export` + ) + const cells = await page.evaluate(() => window.checkPhotographicCells()) + await Bun.write( + resolve(artifacts, "photographic-cells-preview.webp"), + Buffer.from(cells.previewWebp.split(",")[1], "base64") + ) + for (const [name, png] of [ + ["cell-cutout", cells.png], + ["photographic-cells-preview", cells.previewPng], + ]) { + await Bun.write( + resolve(artifacts, `${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + } + console.log( + `PASS photographic cells: ${cells.samples} editor/runtime GPU cases, full-detail interiors, alpha, hydration, runtime export, groups, preview/PNG` + ) + const masks = await page.evaluate(() => window.checkLayerMasks()) + await Bun.write( + resolve(artifacts, "layer-masks.png"), + Buffer.from(masks.png.split(",")[1], "base64") + ) + console.log( + `PASS layer masks: ${masks.samples} editor/runtime GPU cases, group cutouts, brush parity with Cells paint, hydration, history, duplication, export` + ) + const gradientMap = await page.evaluate(() => window.checkGradientMap()) + await Bun.write( + resolve(artifacts, "gradient-map.png"), + Buffer.from(gradientMap.png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "gradient-map-preview.webp"), + Buffer.from(gradientMap.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS gradient map: ${gradientMap.samples} editor/runtime GPU cases, presets, amount, invert, masked group scope, hydration, export, catalog preview` + ) + const gradientMapKeyframes = await page.evaluate(() => + window.checkGradientMapKeyframes() + ) + for (const [name, png] of Object.entries(gradientMapKeyframes.frames)) + await Bun.write( + resolve(artifacts, `gradient-map-keyframes-${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + console.log( + `PASS gradient map keyframes: ${gradientMapKeyframes.samples} cases, ramp interpolation, easing, editor/runtime evaluation and GPU parity, auto-key and keyframe store paths, history, duplication, save/reopen, shader export` + ) + const patternMotifs = await page.evaluate(() => window.checkPatternMotifs()) + await Bun.write( + resolve(artifacts, "pattern-motifs.png"), + Buffer.from(patternMotifs.png.split(",")[1], "base64") + ) + console.log( + `PASS pattern motifs: ${patternMotifs.samples} editor/runtime GPU cases, light-to-dark bands, motif colors, alpha shapes, invert, reorder, failed loads, history, duplication, save/reopen, missing motifs, export, runtime parity` + ) + const lumenPrint = await page.evaluate(() => window.checkLumenPrint()) + for (const [style, png] of Object.entries(lumenPrint.styles)) + await Bun.write( + resolve(artifacts, `lumen-print-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "lumen-print-preview.webp"), + Buffer.from(lumenPrint.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS lumen print: ${lumenPrint.samples} editor/runtime GPU cases, identity, solarize, washout, grain, styles, hydration, history, export, photo renders` + ) + const grain = await page.evaluate(() => window.checkGrain()) + for (const [style, png] of Object.entries(grain.styles)) + await Bun.write( + resolve(artifacts, `grain-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + for (const [frame, png] of Object.entries(grain.frames)) + await Bun.write( + resolve(artifacts, `grain-frame-${frame}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "grain-preview.webp"), + Buffer.from(grain.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS grain: ${grain.samples} editor/runtime GPU cases, identity, mean tone, mono and luminance-neutral chroma, clean blacks and whites, overlay and add, tonal response, size and roughness, document-pixel resolution, clumping, seed, stepped frames, coverage, styles, hydration, history, export, photo renders` + ) + const signalRot = await page.evaluate(() => window.checkSignalRot()) + for (const [style, png] of Object.entries(signalRot.styles)) + await Bun.write( + resolve(artifacts, `signal-rot-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "signal-rot-preview.webp"), + Buffer.from(signalRot.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS signal rot: ${signalRot.samples} editor/runtime GPU cases, identity, drag both directions, tear, dropout, chroma, crush, wobble, speed, styles, hydration, history, export, photo renders` + ) + const dotGrid = await page.evaluate(() => window.checkDotGrid()) + for (const [style, png] of Object.entries(dotGrid.styles)) + await Bun.write( + resolve(artifacts, `dot-grid-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "dot-grid-preview.webp"), + Buffer.from(dotGrid.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS dot grid: ${dotGrid.samples} editor/runtime GPU cases, exact cells, coverage, shapes, min dot, invert, tone, source ink, underlay, styles, hydration, history, export, photo renders` + ) + const erosion = await page.evaluate(() => window.checkErosion()) + for (const [style, png] of Object.entries(erosion.styles)) + await Bun.write( + resolve(artifacts, `erosion-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "erosion-preview.webp"), + Buffer.from(erosion.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS erosion: ${erosion.samples} editor/runtime GPU cases, identity, edge, light, dark and cutout modes, speckle blocks, transparent holes, scatter, speed, styles, hydration, history, export, photo renders` + ) + const relief = await page.evaluate(() => window.checkRelief()) + for (const [style, png] of Object.entries(relief.styles)) + await Bun.write( + resolve(artifacts, `relief-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "relief-preview.webp"), + Buffer.from(relief.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS relief: ${relief.samples} editor/runtime GPU cases, flat identity, emboss, deboss, light angle and elevation, cutout and depth height, engraving, grain, styles, hydration, history, export, photo renders` + ) + const flares = await page.evaluate(() => window.checkFlares()) + for (const [style, png] of Object.entries(flares.styles)) + await Bun.write( + resolve(artifacts, `flares-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "flares-preview.webp"), + Buffer.from(flares.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS flares: ${flares.samples} editor/runtime GPU cases, no false flares, cross, rotation, star, secondary rays, streak, isolation, core color, transparency, core glow, styles, hydration, history, export, renders` + ) + const focusBlur = await page.evaluate(() => window.checkFocusBlur()) + for (const [style, png] of Object.entries(focusBlur.styles)) + await Bun.write( + resolve(artifacts, `focus-blur-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "focus-blur-preview.webp"), + Buffer.from(focusBlur.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS blur: ${focusBlur.samples} editor/runtime GPU cases, identity, flat color, smooth wide radii without ringing, linear, radial, invert, depth with and without a map, lens, motion, alpha edges, grain, styles, hydration, history, export, renders` + ) + const glass = await page.evaluate(() => window.checkGlass()) + for (const [style, png] of Object.entries(glass.styles)) + await Bun.write( + resolve(artifacts, `glass-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "glass-preview.webp"), + Buffer.from(glass.previewWebp.split(",")[1], "base64") + ) + if (glass.failure) throw new Error(glass.failure) + console.log( + `PASS glass: ${glass.samples} editor/runtime GPU cases, identity for every pattern, flute flip, sharp prisms, distance blur, depth, edges, highlights, cell refraction, dispersion, frost, tint, styles, hydration, history, export, renders` + ) + const dots = await page.evaluate(() => window.checkConnectedDots()) + for (const [style, png] of Object.entries(dots.styles)) + await Bun.write( + resolve(artifacts, `connected-dots-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "connected-dots-preview.webp"), + Buffer.from(dots.previewWebp.split(",")[1], "base64") + ) + if (dots.failure) throw new Error(dots.failure) + console.log( + `PASS connected dots: ${dots.samples} editor/runtime GPU cases, empty light areas, dots, links, blobs, tone sizes, cutoff, invert, shapes, plexus range, palette, source, transparency, drift, styles, hydration, history, export, renders` + ) + const dotsEdges = await page.evaluate(() => window.checkConnectedDotsEdges()) + for (const [name, png] of Object.entries(dotsEdges.renders)) + await Bun.write( + resolve(artifacts, `connected-dots-edges-${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + console.log( + `PASS connected dots edge snap: ${dotsEdges.samples} editor/runtime GPU cases, neutral identity, flat and grainy images, step edges, halfway snapping, noise stability, thin lines, cutout edges, blobs, plexus, mesh edges and facets, styles, hydration, history, duplication, save/reopen, export, runtime parity, renders` + ) + const plotter = await page.evaluate(() => window.checkPlotter()) + for (const [style, png] of Object.entries(plotter.styles)) + await Bun.write( + resolve(artifacts, `plotter-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "plotter-preview.webp"), + Buffer.from(plotter.previewWebp.split(",")[1], "base64") + ) + if (plotter.failure) throw new Error(plotter.failure) + console.log( + `PASS plotter: ${plotter.samples} editor/runtime GPU cases, blank paper and strokes for every mode, line coverage, crosshatch, tone, pressure, wobble, squiggle, three pens, transparent paper, legacy defaults, styles, hydration, history, export, renders` + ) + const photocopy = await page.evaluate(() => window.checkPhotocopy()) + for (const [style, png] of Object.entries(photocopy.styles)) + await Bun.write( + resolve(artifacts, `photocopy-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "photocopy-preview.webp"), + Buffer.from(photocopy.previewWebp.split(",")[1], "base64") + ) + if (photocopy.failure) throw new Error(photocopy.failure) + console.log( + `PASS photocopy: ${photocopy.samples} editor/runtime GPU cases, blank paper, solid toner, contrast crush, speckle, streaks, misregistration, generations, creases, transparent paper, styles, hydration, history, export, renders` + ) + const outline = await page.evaluate(() => window.checkOutline()) + for (const [style, png] of Object.entries(outline.styles)) + await Bun.write( + resolve(artifacts, `outline-${style}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "outline-preview.webp"), + Buffer.from(outline.previewWebp.split(",")[1], "base64") + ) + if (outline.failure) throw new Error(outline.failure) + console.log( + `PASS outline: ${outline.samples} editor/runtime GPU cases, offset, rings, inside, fill, line only, scalloped cloud, dashes, dark shapes, styles, hydration, history, export, renders` + ) + const artboard = await page.evaluate(() => window.checkArtboard()) + console.log( + `PASS artboard: ${artboard.samples} document size, fit, composition update, save/reopen and legacy checks` + ) + const shapes = await page.evaluate(() => window.checkShapeLayers()) + await Bun.write( + resolve(artifacts, "shape-layers.png"), + Buffer.from(shapes.png.split(",")[1], "base64") + ) + console.log( + `PASS shape layers: ${shapes.samples} editor/runtime GPU cases across seven shapes, outline, softness, blend, mask, hydration, export` + ) + const svgShapes = await page.evaluate(() => window.checkSvgShapes()) + await Bun.write( + resolve(artifacts, "svg-shapes.png"), + Buffer.from(svgShapes.png.split(",")[1], "base64") + ) + console.log( + `PASS SVG shapes: ${svgShapes.samples} editor/runtime GPU cases, file colors, palette, single color, outline, softness against the procedural rectangle, width/height-only SVGs, history, duplication, save/reopen, missing SVG, export, runtime parity` + ) + const textEditing = await page.evaluate(() => window.checkTextEditing()) + await Bun.write( + resolve(artifacts, "text-editing.png"), + Buffer.from(textEditing.png.split(",")[1], "base64") + ) + console.log( + `PASS text editing: ${textEditing.samples} editor/runtime checks for multiline, align, line height, rotation, small sizes, geometry helpers, hydration and export` + ) + const blob = await page.evaluate(() => window.checkBlobTracking()) + console.log( + `PASS blob tracking: ${blob.samples} tracker edge points, seeded labels, atlas glyphs, editor/runtime frames, brackets, edge dots, label modes, migration` + ) + const annotations = await page.evaluate(() => window.checkAnnotations()) + await Bun.write( + resolve(artifacts, "annotations.png"), + Buffer.from(annotations.png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "annotations-preview.webp"), + Buffer.from(annotations.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS annotations: ${annotations.samples} layout, placement, text, palette, editor/runtime render, hydration and export checks` + ) + const annotationExtras = await page.evaluate(() => window.checkAnnotationExtras()) + for (const [name, png] of Object.entries(annotationExtras.stills)) + await Bun.write( + resolve(artifacts, `annotation-extras-${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + console.log( + `PASS annotation extras: ${annotationExtras.samples} region segmentation, persistent region ids, region placement, connected dots, rotation jitter, align to edges, legacy layout digest, editor/runtime GPU parity, history, duplication, save/reopen, export and video stability checks` + ) + const depth = await page.evaluate(() => window.checkDepthParallax()) + await Bun.write( + resolve(artifacts, "depth-parallax.png"), + Buffer.from(depth.png.split(",")[1], "base64") + ) + console.log( + `PASS depth parallax: ${depth.samples} checks, editor/runtime GPU parity, occlusion march, edges, depth view, scene depth for effects and masks, motion, hydration, export, both pipelines` + ) + console.log( + `Depth media pass at 1080p (software adapter, median ms): ${JSON.stringify(depth.timing)}` + ) + const model = await page.evaluate(() => window.checkModelLayer()) + await Bun.write( + resolve(artifacts, "model-layer.png"), + Buffer.from(model.png.split(",")[1], "base64") + ) + console.log( + `PASS 3D model: ${model.samples} checks, glTF import and fit, transform, camera shift, blend and opacity, exposure and tone mapping, material replacement and restore, exact scene depth for effects and depth masks, groups, custom .hdr lighting, preview/export parity, pose redraw, hydration, history, export` + ) + const animation = await page.evaluate(() => window.checkModelAnimation()) + console.log( + `PASS 3D animation: ${animation} checks, clip timing, default clips, clip-wide framing, animated depth, skinning, speed, pause, repeat modes, start, timeline seeding, save/reopen, export` + ) + const svg = await page.evaluate(() => window.checkModelSvg()) + console.log( + `PASS SVG to 3D: ${svg} checks, holes, bevel inside the outline, strokes, stacked paths, y-up, fill colors, text-only message, extrusion depth, materials, depth, save/reopen` + ) + assert.deepEqual(unexpectedErrors(), [], "Browser or GPU errors occurred") + if (errors.length > 0) + console.log( + `Ignored ${errors.length} SwiftShader video frame import errors (Chrome cannot import decoded video frames into SwiftShader)` + ) + console.log( + update + ? "Baseline capture complete. Review images before committing." + : "Composition baselines passed." + ) +} finally { + await browser?.close() + server.stop(true) +} diff --git a/tests/composition/shape-layers-ui.mjs b/tests/composition/shape-layers-ui.mjs new file mode 100644 index 00000000..9528a2e3 --- /dev/null +++ b/tests/composition/shape-layers-ui.mjs @@ -0,0 +1,195 @@ +// Start the dev server, then bun tests/composition/shape-layers-ui.mjs. +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +await mkdir(".context", { recursive: true }) +const base = createLayer("gradient") +await Bun.write( + ".context/shape-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: "shape", + assets: [], + layers: [ + { ...createLayer("shape"), id: "shape", name: "Shape" }, + { ...base, id: "field", name: "Field", params: { ...base.params, motionStyle: "off" } }, + ], + sceneConfig: { + ...DEFAULT_SCENE_CONFIG, + compositionAspect: "custom", + compositionWidth: 720, + compositionHeight: 960, + }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await ready() + const button = (name) => + page.getByRole("button", { name, exact: true }).filter({ visible: true }) + async function importFile(path) { + await button("Export").click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(path) + await page.getByRole("dialog").waitFor({ state: "hidden" }) + await panel.locator('[data-layer-row="shape"]').waitFor() + await ready() + } + async function save(name) { + await button("Export").click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const download = await downloaded + const path = `.context/shape-${name}.lab` + await download.saveAs(path) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return await Bun.file(path).json() + } + const paramsOf = (project, id = "shape") => + project.layers.find((l) => l.id === id).params + + await importFile(".context/shape-ui-fixture.lab") + await panel + .locator('[data-layer-row="shape"]') + .getByText("Shape", { exact: true }) + .click() + const handles = page.locator('[data-shape-handles="true"]') + const stage = await page.locator('[data-editor-canvas="true"]').first().boundingBox() + await page.mouse.move(stage.x + stage.width / 2, stage.y + stage.height / 2) + await handles.waitFor() + assert.equal(await page.locator('[data-mask-handles="true"]').count(), 0) + const initial = paramsOf(await save("initial")) + assert.deepEqual(initial.center, [0, 0]) + + const center = page.locator('[data-shape-handle="center"]') + const cb = await center.boundingBox() + const ccx = cb.x + cb.width / 2 + const ccy = cb.y + cb.height / 2 + await page.mouse.move(ccx, ccy) + await page.mouse.down() + await page.mouse.move(ccx + 40, ccy + 30, { steps: 6 }) + await page.mouse.move(ccx + 90, ccy + 60, { steps: 6 }) + await page.mouse.up() + await page.waitForTimeout(400) + const moved = paramsOf(await save("moved")) + assert.ok(moved.center[0] > 0.05 && moved.center[1] > 0.03, `Center drag ${moved.center}`) + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.deepEqual(paramsOf(await save("moved-undo")).center, [0, 0], "One drag is one undo step") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(400) + + const rotateZone = page.locator('[data-shape-handle="rotate-se"]') + const rz = await rotateZone.boundingBox() + await page.mouse.move(rz.x + rz.width / 2, rz.y + rz.height / 2) + await page.mouse.down() + await page.mouse.move(rz.x - 20, rz.y + 70, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(400) + const turned = paramsOf(await save("turned")) + assert.ok(Math.abs(turned.rotation) > 5, "Corner zone rotates the shape") + assert.deepEqual(turned.size, moved.size, "Rotating keeps the size") + + const corner = page.locator('[data-shape-handle="se"]') + const sb = await corner.boundingBox() + await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2) + await page.mouse.down() + await page.mouse.move(sb.x + 60, sb.y + 10, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(400) + const rotated = paramsOf(await save("rotated")) + assert.notEqual(rotated.size[0], turned.size[0], "Corner resizes width") + assert.notEqual(rotated.size[1], turned.size[1], "Corner resizes height") + assert.equal(rotated.rotation, turned.rotation, "Resizing keeps the rotation") + + const shapeSelect = page + .getByRole("combobox") + .filter({ hasText: /^Ellipse$/ }) + .filter({ visible: true }) + .first() + await shapeSelect.click() + await page.getByRole("option", { name: "Blades", exact: true }).click() + await page.waitForTimeout(400) + await page.getByRole("slider", { name: /^Twist/ }).filter({ visible: true }).waitFor() + assert.equal(await page.getByRole("slider", { name: /^Corner Radius/ }).count(), 0) + assert.equal(paramsOf(await save("blades")).shape, "blades") + await page.screenshot({ path: ".context/shape-layers-ui.png" }) + + const add = panel.getByRole("button", { name: "Add layer", exact: true }) + await add.click() + const menu = await add.getAttribute("aria-controls") + await page + .locator(`[id="${menu}"]`) + .getByRole("button", { name: /^Shape$/ }) + .click() + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-layer-sidebar-panel="true"] [data-layer-row]' + ).length >= 3 + ) + await ready() + const added = await save("added") + assert.equal(added.layers.filter((l) => l.type === "shape").length, 2, "Picker adds a Shape") + + await importFile(".context/shape-blades.lab") + const reopened = paramsOf(await save("reopened")) + assert.equal(reopened.shape, "blades") + assert.deepEqual(reopened.center, rotated.center) + assert.deepEqual(errors, []) + console.log( + "PASS shape handles drag/rotate, undo granularity, shape select and conditional params, picker entry, save/reopen" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/shape-layers.mjs b/tests/composition/shape-layers.mjs new file mode 100644 index 00000000..c54310a8 --- /dev/null +++ b/tests/composition/shape-layers.mjs @@ -0,0 +1,283 @@ +import { ShapePass as RuntimeShapePass } from "@runtime/renderer/shape-pass" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { float, texture, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { SHAPE_KINDS, ShapePass } from "@/renderer/shape-pass" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { getLayerCatalogEntry } from "@/lib/editor/config/layer-catalog" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_LAYER_MASK, DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.01) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) +const N = 96 +const index = (v) => Math.min(N - 1, Math.max(0, Math.round((v + 0.5) * N - 0.5))) +const RED = [linear(1), linear(0x4a / 255), linear(0x2a / 255)] + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0, 0, 1, 1] + const input = new THREE.DataTexture(new Float32Array(backdrop), 1, 1, THREE.RGBAFormat, THREE.FloatType) + input.needsUpdate = true + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const results = {} + let samples = 0 + try { + for (const [name, Pass] of [ + ["editor", ShapePass], + ["runtime", RuntimeShapePass], + ]) { + const pass = new Pass(`shape-${name}`) + pass.updateCompositionRole("source") + const render = async (params, blend = "normal") => { + pass.updateBlendMode(blend) + pass.flushColorNode() + pass.resize(N, N) + pass.updateLogicalSize(N, N) + pass.updateParams({ ...createLayer("shape").params, ...params }) + pass.updateOpacity(1) + pass.render(renderer, input, target, 0, 0) + const pixels = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + return (x, y) => { + const i = (index(y) * N + index(x)) * 4 + return pixels.slice(i, i + 4) + } + } + const check = (label, actual, expected, tolerance) => { + close(actual, expected, `${name}: ${label}`, tolerance) + results[label] ??= {} + results[label][name] = actual + samples++ + } + const coverage = (at, x, y) => at(x, y)[0] / RED[0] + let at = await render({ shape: "ellipse", size: [0.6, 0.6] }) + check("ellipse center", at(0, 0), [...RED, 1], 0.02) + check("ellipse inside", at(0.25, 0), [...RED, 1], 0.02) + check("ellipse outside", at(0.35, 0), backdrop, 0.02) + check("ellipse corner", at(0.4, 0.4), backdrop, 0.02) + at = await render({ shape: "ellipse", size: [0.6, 0.3] }) + check("ellipse height", at(0, 0.2), backdrop, 0.02) + at = await render({ shape: "ellipse", size: [0.6, 0.3], rotation: 90 }) + check("rotated ellipse", at(0, 0.2), [...RED, 1], 0.02) + check("rotated ellipse side", at(0.2, 0), backdrop, 0.02) + at = await render({ shape: "ellipse", size: [0.4, 0.4], center: [0.25, -0.2] }) + check("offset ellipse", at(0.25, -0.2), [...RED, 1], 0.02) + check("offset ellipse origin", at(0, 0), backdrop, 0.02) + + at = await render({ shape: "rectangle", size: [0.6, 0.4], cornerRadius: 0 }) + check("rectangle corner filled", at(0.28, 0.18), [...RED, 1], 0.02) + check("rectangle outside", at(0.32, 0), backdrop, 0.02) + at = await render({ shape: "rectangle", size: [0.6, 0.4], cornerRadius: 1 }) + check("rounded corner cut", at(0.28, 0.18), backdrop, 0.02) + check("rounded center", at(0, 0), [...RED, 1], 0.02) + + at = await render({ shape: "triangle", size: [0.8, 0.8] }) + check("triangle apex column", at(0, -0.3), [...RED, 1], 0.02) + check("triangle empty top corner", at(0.3, -0.3), backdrop, 0.02) + check("triangle base", at(0.3, 0.15), [...RED, 1], 0.02) + + at = await render({ shape: "polygon", size: [0.8, 0.8], sides: 6 }) + const hexEdge = coverage(at, 0.33, 0) + at = await render({ shape: "polygon", size: [0.8, 0.8], sides: 3 }) + const triEdge = coverage(at, 0.33, 0) + assert(hexEdge > 0.9 && triEdge < 0.1, `${name}: polygon sides change the silhouette (${hexEdge}, ${triEdge})`) + samples++ + + at = await render({ shape: "star", size: [0.8, 0.8], points: 5, innerRadius: 0.4 }) + check("star tip", at(0, -0.36), [...RED, 1], 0.02) + assert(coverage(at, 0, 0.36) < 0.1, `${name}: star valley opposite the tip`) + assert(coverage(at, 0, 0) > 0.9, `${name}: star core`) + samples += 2 + + at = await render({ shape: "ring", size: [0.8, 0.8], thickness: 0.3 }) + check("ring hole", at(0, 0), backdrop, 0.02) + check("ring band", at(0.34, 0), [...RED, 1], 0.02) + + at = await render({ shape: "blades", size: [0.9, 0.9], blades: 4, twist: 0, bladeWidth: 0.55, hub: 0.12 }) + const bladeAxis = coverage(at, 0, -0.35) + const bladeGap = coverage(at, 0.25, -0.25) + assert(bladeAxis > 0.9 && bladeGap < 0.1, `${name}: four blades leave gaps at 45° (${bladeAxis}, ${bladeGap})`) + check("blades hub", at(0, 0), [...RED, 1], 0.02) + + at = await render({ shape: "ellipse", size: [0.6, 0.6], outline: 0.05 }) + check("outline hole", at(0, 0), backdrop, 0.02) + check("outline edge", at(0.29, 0), [...RED, 1], 0.02) + + at = await render({ shape: "ellipse", size: [0.6, 0.6], softness: 0.1 }) + const soft = coverage(at, 0.3, 0) + assert(soft > 0.3 && soft < 0.7, `${name}: softness feathers the edge (${soft})`) + check("soft interior", at(0, 0), [...RED, 1], 0.02) + samples++ + + at = await render({ shape: "ellipse", size: [0.6, 0.6], color: "#00ff00" }) + check("color", at(0, 0), [0, 1, 0, 1], 0.02) + + at = await render({ shape: "ellipse", size: [0.6, 0.6] }, "multiply") + check("multiply over blue", at(0, 0), [0, 0, RED[2], 1], 0.02) + check("multiply outside untouched", at(0.4, 0.4), backdrop, 0.02) + + pass.dispose() + } + for (const [label, entry] of Object.entries(results)) + close(entry.editor, entry.runtime, `parity: ${label}`, 0.002) + samples += Object.keys(results).length + } finally { + target.dispose() + input.dispose() + renderer.dispose() + } + return samples +} + +function solid(id, color) { + const layer = createLayer("gradient") + return { + ...layer, + id, + params: { + ...layer.params, + motionStyle: "off", + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries([1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color])), + }, + } +} + +async function runtimeRender(config) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const headless = createHeadlessRenderer({ renderer, size: config.composition }) + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const material = new THREE.MeshBasicNodeMaterial({ blending: THREE.NoBlending }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, config.composition) + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + material.colorNode = texture(headless.render(frame), vec2(uv().x, float(1).sub(uv().y))) + renderer.setRenderTarget(target) + renderer.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)) + return Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + } finally { + headless.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkShapeLayers(renderProject) { + assert(SHAPE_KINDS.length === 8 && SHAPE_KINDS.at(-1) === "svg", "Eight shape kinds, SVG last") + assert(getLayerCatalogEntry("shape").label === "Shape", "Catalog entry") + const fresh = createLayer("shape") + assert(fresh.kind === "source" && fresh.params.shape === "ellipse" && fresh.params.color === "#ff4a2a", "New shape defaults") + let samples = 3 + samples += await passChecks() + + const shape = { + ...createLayer("shape"), + id: "shape", + blendMode: "multiply", + params: { ...createLayer("shape").params, shape: "blades", size: [0.8, 0.8], center: [0.1, 0], rotation: 20 }, + mask: { ...DEFAULT_LAYER_MASK, shape: "rectangle", size: [2, 0.6], feather: 0 }, + } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [shape, solid("photo", "#8899ff")], + selectedLayerId: shape.id, + composition: { width: N, height: N }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const store = () => useLayerStore.getState() + const before = buildEditorHistorySnapshot() + store().updateLayerParam(shape.id, "shape", "star") + store().updateLayerParam(shape.id, "center", [0.3, 0.3]) + applyEditorHistorySnapshot(before) + assert( + store().getLayerById(shape.id).params.shape === "blades" && + store().getLayerById(shape.id).params.center[0] === 0.1, + "History lost shape settings" + ) + const duplicateId = store().duplicateLayer(shape.id) + store().updateLayerParam(duplicateId, "rotation", 90) + assert(store().getLayerById(shape.id).params.rotation === 20, "Duplicate must be independent") + applyEditorHistorySnapshot(before) + const saved = buildLabProjectFile() + store().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), []) + const reopened = buildLabProjectFile() + const reopenedShape = reopened.layers.find((l) => l.id === shape.id) + assert( + reopenedShape.type === "shape" && + reopenedShape.params.shape === "blades" && + reopenedShape.blendMode === "multiply" && + reopenedShape.mask.shape === "rectangle", + "Save/reopen changed the shape layer" + ) + const config = buildShaderExportConfig(reopened) + assert(config.layers.find((l) => l.id === shape.id).type === "shape", "Shader export type") + samples += 4 + + const first = await renderProject(saved) + const at = (image, x, y) => { + const i = (index(y) * N + index(x)) * 4 + return Array.from(image.data.slice(i, i + 4)) + } + const hub = at(first.image, 0.1, 0) + const outsideMask = at(first.image, 0.1, 0.42) + assert(hub[2] < 120 && hub[0] > 100, `Multiplied blades tint the photo at the hub (${hub})`) + close(outsideMask, [0x88, 0x99, 0xff, 255], "Rectangle mask removes the shape outside its band", 2) + const restored = await renderProject(reopened) + close(Array.from(restored.image.data), Array.from(first.image.data), "Reopened pixels", 0) + const runtimePixels = await runtimeRender(config) + close( + runtimePixels, + Array.from(first.image.data, (value, i) => { + const v = value / 255 + return i % 4 === 3 ? v : linear(v) + }), + "Exported runtime shape parity", + 0.02 + ) + samples += 4 + return { samples, png: first.png } +} diff --git a/tests/composition/signal-rot.mjs b/tests/composition/signal-rot.mjs new file mode 100644 index 00000000..f7e36aba --- /dev/null +++ b/tests/composition/signal-rot.mjs @@ -0,0 +1,355 @@ +import { SignalRotPass as RuntimeSignalRot } from "@runtime/renderer/signal-rot-pass" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { float, texture, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { SignalRotPass } from "@/renderer/signal-rot-pass" +import { + DEFAULT_SIGNAL_ROT_STYLE, + matchSignalRotStyle, + SIGNAL_ROT_STYLES, + signalRotStyleParams, +} from "@/lib/editor/config/signal-rot-styles" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { getLayerCatalogEntry } from "@/lib/editor/config/layer-catalog" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.01) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) +const N = 64 + +const NEUTRAL = { + direction: "vertical", + drag: 0, + dragLength: 0.3, + stretch: 0, + wobble: 0, + wobbleScale: 0.5, + tear: 0, + bandSize: 0.12, + dropout: 0, + dropoutColor: "#ffffff", + chroma: 0, + crush: 0, + lineNoise: 0, + speed: 0, + seed: 0, +} + +function unitChecks() { + const entry = getLayerCatalogEntry("signal-rot") + assert(entry.label === "Signal Rot" && entry.category === "distort", "Catalog entry") + const layer = createLayer("signal-rot") + assert( + matchSignalRotStyle(layer.params) === DEFAULT_SIGNAL_ROT_STYLE.id, + "New layers start on the Scanner Drag style" + ) + for (const style of SIGNAL_ROT_STYLES) + assert( + matchSignalRotStyle({ ...layer.params, ...signalRotStyleParams(style) }) === style.id, + `${style.id}: applying a style must select it` + ) + assert(matchSignalRotStyle({ ...layer.params, tear: 0.99 }) === "custom", "Editing shows Custom") + assert( + matchSignalRotStyle({ ...layer.params, dropoutColor: "#FFFFFF" }) === DEFAULT_SIGNAL_ROT_STYLE.id, + "Color matching ignores case" + ) + return 4 + SIGNAL_ROT_STYLES.length +} + +function makeInput(fn) { + const data = new Float32Array(N * N * 4) + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) data.set([...fn(x / (N - 1), y / (N - 1)), 1], (y * N + x) * 4) + const tex = new THREE.DataTexture(data, N, N, THREE.RGBAFormat, THREE.FloatType) + tex.needsUpdate = true + return tex +} + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const inputs = { + along: makeInput((_x, y) => [y, y, y]), + across: makeInput((x) => [x, x, x]), + rich: makeInput((x, y) => [x, (x * 3 + y * 5) % 1, y]), + } + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const results = {} + let samples = 0 + try { + for (const [name, Pass] of [ + ["editor", SignalRotPass], + ["runtime", RuntimeSignalRot], + ]) { + const pass = new Pass(`signal-rot-${name}`) + pass.updateCompositionRole("effect") + pass.flushColorNode() + const render = async (input, params, time = 0) => { + pass.resize(N, N) + pass.updateLogicalSize(N, N) + pass.updateParams(params) + pass.render(renderer, inputs[input], target, time, 0) + return Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + } + const record = (label, pixels) => { + results[label] ??= {} + results[label][name] = pixels + samples++ + } + const source = (input) => Array.from(inputs[input].image.data) + const column = (pixels, x) => Array.from({ length: N }, (_, y) => pixels[(y * N + x) * 4]) + const row = (pixels, y) => Array.from({ length: N }, (_, x) => pixels[(y * N + x) * 4]) + const distinct = (values) => new Set(values.map((v) => v.toFixed(4))).size + + let px = await render("rich", NEUTRAL) + close(px, source("rich"), `${name}: neutral settings are the identity`, 0.0005) + record("identity", px) + + px = await render("along", { ...NEUTRAL, drag: 1, dragLength: 0.5 }) + const heldColumns = [4, 20, 40, 60].map((x) => distinct(column(px, x))) + assert( + heldColumns.every((count) => count <= 6), + `${name}: full drag must hold columns into streaks (${heldColumns})` + ) + record("vertical drag", px) + + px = await render("across", { ...NEUTRAL, direction: "horizontal", drag: 1, dragLength: 0.5 }) + const heldRows = [4, 30, 60].map((y) => distinct(row(px, y))) + assert(heldRows.every((count) => count <= 6), `${name}: horizontal drag holds rows (${heldRows})`) + record("horizontal drag", px) + + px = await render("across", { ...NEUTRAL, tear: 1, bandSize: 0.1 }) + const src = source("across") + const shiftedRows = Array.from({ length: N }, (_, y) => y).filter((y) => + row(px, y).some((v, x) => Math.abs(v - src[(y * N + x) * 4]) > 0.02) + ).length + assert(shiftedRows > N * 0.2, `${name}: tear must shift bands (${shiftedRows} rows)`) + record("tear", px) + + px = await render("rich", { ...NEUTRAL, dropout: 1, dropoutColor: "#ff0000", bandSize: 0.1 }) + let red = 0 + for (let i = 0; i < px.length; i += 4) + if (px[i] > 0.99 && px[i + 1] < 0.01 && px[i + 2] < 0.01) red++ + assert(red > N * N * 0.1 && red < N * N, `${name}: dropout fills part of the bands (${red})`) + record("dropout", px) + + px = await render("along", { ...NEUTRAL, chroma: 1 }) + const split = px[(32 * N + 32) * 4] - px[(32 * N + 32) * 4 + 1] + assert(Math.abs(split) > 0.01, `${name}: chroma shift separates channels (${split})`) + record("chroma", px) + + px = await render("rich", { ...NEUTRAL, crush: 1 }) + const levels = new Set() + for (let i = 0; i < px.length; i += 4) levels.add(px[i].toFixed(3)) + assert(levels.size <= 4, `${name}: full crush leaves at most 4 levels (${levels.size})`) + record("crush", px) + + px = await render("rich", { ...NEUTRAL, wobble: 1, wobbleScale: 0.3 }) + assert(px.every(Number.isFinite), `${name}: wobble output is finite`) + record("wobble", px) + + const still = { ...NEUTRAL, lineNoise: 1 } + const a = await render("rich", still, 0) + const b = await render("rich", still, 3) + close(a, b, `${name}: speed 0 keeps the damage fixed`, 0) + assert(!pass.needsContinuousRender(), `${name}: static damage needs no continuous render`) + const moving = { ...still, speed: 1 } + const c = await render("rich", moving, 0) + const d = await render("rich", moving, 1.3) + assert(pass.needsContinuousRender(), `${name}: speed requests continuous render`) + assert(c.some((v, i) => Math.abs(v - d[i]) > 0.01), `${name}: speed animates the rot`) + record("line noise", c) + samples += 2 + + for (const style of SIGNAL_ROT_STYLES) { + px = await render("rich", signalRotStyleParams(style), 0.5) + assert(px.every(Number.isFinite), `${name}: ${style.id} produced non-finite output`) + record(`style ${style.id}`, px) + } + pass.dispose() + } + for (const [label, entry] of Object.entries(results)) + close(entry.editor, entry.runtime, `parity: ${label}`, 0.002) + samples += Object.keys(results).length + } finally { + target.dispose() + for (const input of Object.values(inputs)) input.dispose() + renderer.dispose() + } + return samples +} + +function stripes(id) { + const layer = createLayer("gradient") + return { + ...layer, + id, + params: { + ...layer.params, + motionStyle: "off", + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + }, + } +} + +async function runtimeRender(config) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const headless = createHeadlessRenderer({ renderer, size: config.composition }) + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const material = new THREE.MeshBasicNodeMaterial({ blending: THREE.NoBlending }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, config.composition) + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + material.colorNode = texture(headless.render(frame), vec2(uv().x, float(1).sub(uv().y))) + renderer.setRenderTarget(target) + renderer.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)) + return Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + } finally { + headless.dispose() + target.dispose() + renderer.dispose() + } +} + +function toWebp(image) { + const canvas = document.createElement("canvas") + canvas.width = image.width + canvas.height = image.height + canvas.getContext("2d").putImageData(image, 0, 0) + return canvas.toDataURL("image/webp", 0.85) +} + +export async function checkSignalRot(renderProject) { + let samples = unitChecks() + samples += await passChecks() + + const rot = { ...createLayer("signal-rot"), id: "rot" } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [rot, stripes("field")], + selectedLayerId: rot.id, + composition: { width: N, height: N }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const store = () => useLayerStore.getState() + const before = buildEditorHistorySnapshot() + const tape = SIGNAL_ROT_STYLES.find((s) => s.id === "signal-rot") + for (const [key, value] of Object.entries(signalRotStyleParams(tape))) + store().updateLayerParam(rot.id, key, value) + assert(matchSignalRotStyle(store().getLayerById(rot.id).params) === "signal-rot", "Style edits reach the store") + applyEditorHistorySnapshot(before) + assert( + matchSignalRotStyle(store().getLayerById(rot.id).params) === DEFAULT_SIGNAL_ROT_STYLE.id, + "History lost signal rot settings" + ) + const duplicateId = store().duplicateLayer(rot.id) + store().updateLayerParam(duplicateId, "direction", "horizontal") + assert(store().getLayerById(rot.id).params.direction === "vertical", "Duplicate must be independent") + applyEditorHistorySnapshot(before) + const saved = buildLabProjectFile() + store().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), []) + const reopened = buildLabProjectFile() + const reopenedRot = reopened.layers.find((l) => l.id === rot.id) + assert( + reopenedRot.type === "signal-rot" && matchSignalRotStyle(reopenedRot.params) === DEFAULT_SIGNAL_ROT_STYLE.id, + "Save/reopen changed the signal rot" + ) + const config = buildShaderExportConfig(reopened) + assert(config.layers.find((l) => l.id === rot.id).type === "signal-rot", "Shader export type") + samples += 4 + + const first = await renderProject(saved) + const restored = await renderProject(reopened) + close(Array.from(restored.image.data), Array.from(first.image.data), "Reopened pixels", 0) + const runtimePixels = await runtimeRender(config) + close( + runtimePixels, + Array.from(first.image.data, (value, i) => { + const v = value / 255 + return i % 4 === 3 ? v : linear(v) + }), + "Exported runtime signal rot parity", + 0.02 + ) + samples += 2 + + const asset = { + id: "photo-asset", + kind: "image", + url: "/scenes/default/editorial/flora.webp", + fileName: "flora.webp", + width: 1512, + height: 908, + } + const styles = {} + for (const style of SIGNAL_ROT_STYLES) { + const base = createLayer("signal-rot") + const rendered = await renderProject({ + ...saved, + composition: { width: 756, height: 454 }, + assets: [asset], + layers: [ + { ...base, id: "preview-rot", params: { ...base.params, ...signalRotStyleParams(style) } }, + { ...createLayer("image"), id: "photo", assetId: asset.id }, + ], + selectedLayerId: "preview-rot", + }) + styles[style.id] = rendered.png + samples++ + } + const catalog = await renderProject({ + ...saved, + composition: { width: 480, height: 600 }, + assets: [asset], + layers: [ + { ...createLayer("signal-rot"), id: "catalog-rot" }, + { ...createLayer("image"), id: "photo", assetId: asset.id }, + ], + selectedLayerId: "catalog-rot", + }) + return { samples, styles, previewWebp: toWebp(catalog.image) } +} diff --git a/tests/composition/svg-shapes.mjs b/tests/composition/svg-shapes.mjs new file mode 100644 index 00000000..01d86321 --- /dev/null +++ b/tests/composition/svg-shapes.mjs @@ -0,0 +1,319 @@ +import { ShapePass as RuntimeShapePass } from "@runtime/renderer/shape-pass" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { float, texture, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { ShapePass } from "@/renderer/shape-pass" +import { + applySvgPalette, + extractSvgColors, + parseSvgPalette, + readSvgAspect, + serializeSvgPalette, +} from "@/renderer/svg-palette" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.01) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) +const N = 96 +const index = (v) => Math.min(N - 1, Math.max(0, Math.round((v + 0.5) * N - 0.5))) +const TWO_TONE = "/scenes/default/shape-two-tone.svg" +const WIDE = "/scenes/default/shape-wide.svg" + +function unitChecks() { + const pinwheel = `` + const colors = extractSvgColors(pinwheel) + assert( + colors.join() === "#ff0000,#00ff00,#aabbcc,#0000ff", + `Colors come from attributes, styles and gradient stops, got ${colors}` + ) + const implicit = extractSvgColors(``) + assert(implicit.join() === "#000000", `A shape without fill lists the implicit black, got ${implicit}`) + const recolored = applySvgPalette( + ``, + { "#ff0000": "#123456", "#000000": "#abcdef" } + ) + assert( + recolored.includes('fill="#123456"') && + recolored.includes("fill:#123456") && + recolored.includes(''), + `Palette rewrites attributes, styles and the implicit fill, got ${recolored}` + ) + assert( + serializeSvgPalette(parseSvgPalette('{"RED":"#00FF00","#fff":"#ffffff","x":"#000"}')) === '{"#ff0000":"#00ff00"}', + "Palette parsing normalizes colors and drops no-ops and invalid entries" + ) + assert(parseSvgPalette("not json") && Object.keys(parseSvgPalette("[1]")).length === 0, "Invalid palettes are empty") + assert(Math.abs(readSvgAspect(``) - 100 / 60) < 1e-9, "Aspect from viewBox") + assert(readSvgAspect(``) === 2, "Aspect from width and height") + const shape = createLayer("shape") + assert(shape.params.svgColorMode === "original" && shape.params.svgPalette === "", "New shapes default to original SVG colors") + return 8 +} + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0, 0, 0, 0] + const input = new THREE.DataTexture(new Float32Array(backdrop), 1, 1, THREE.RGBAFormat, THREE.FloatType) + input.needsUpdate = true + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const results = {} + let samples = 0 + try { + for (const [name, Pass] of [ + ["editor", ShapePass], + ["runtime", RuntimeShapePass], + ]) { + const pass = new Pass(`svg-shape-${name}`) + pass.updateCompositionRole("source") + pass.flushColorNode() + pass.resize(N, N) + pass.updateLogicalSize(N, N) + const render = async (params, svg = TWO_TONE) => { + pass.updateParams({ ...createLayer("shape").params, shape: "svg", size: [0.6, 0.6], ...params }) + await pass.setSvg(svg) + pass.render(renderer, input, target, 0, 0) + const pixels = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + return (x, y) => { + const i = (index(y) * N + index(x)) * 4 + return pixels.slice(i, i + 4) + } + } + const check = (label, actual, expected, tolerance = 0.02) => { + close(actual, expected, `${name}: ${label}`, tolerance) + results[label] ??= {} + results[label][name] = actual + samples++ + } + + let at = await render({}) + check("original left color", at(-0.15, 0), [1, 0, 0, 1]) + check("original right color from a style fill", at(0.15, 0), [0, 0, 1, 1]) + check("outside the SVG is empty", at(0.4, 0), backdrop) + check("corner of the box is filled", at(-0.28, -0.28), [1, 0, 0, 1]) + + at = await render({ svgColorMode: "single", color: "#00ff00" }) + check("single color fills every part", at(0.15, 0), [0, 1, 0, 1]) + + at = await render({ svgPalette: '{"#ff0000":"#ffffff"}' }) + check("palette recolors one file color", at(-0.15, 0), [1, 1, 1, 1]) + check("palette leaves other colors", at(0.15, 0), [0, 0, 1, 1]) + + at = await render({ outline: 0.04, color: "#00ff00" }) + check("outline empties the interior", at(0, 0), backdrop) + check("outline draws in the layer color", at(-0.3, 0), [0, 1, 0, 1], 0.05) + + const alphaRow = (sampler) => Array.from({ length: 24 }, (_, i) => sampler(0.18 + i * 0.01, 0.1)[3]) + at = await render({ softness: 0.05 }) + const soft = at(0.3, 0)[3] + assert(soft > 0.3 && soft < 0.7, `${name}: softness feathers the SVG edge (${soft})`) + check("soft edge keeps the file color instead of black", at(0.31, 0).slice(0, 3).map((v) => (v > 0 ? 1 : 0)), [0, 0, 1]) + const svgSoft = alphaRow(at) + pass.updateParams({ ...createLayer("shape").params, shape: "rectangle", cornerRadius: 0, size: [0.6, 0.6], softness: 0.05 }) + pass.render(renderer, input, target, 0, 0) + const rectPixels = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + const rectSoft = Array.from({ length: 24 }, (_, i) => { + const j = (index(0.1) * N + index(0.18 + i * 0.01)) * 4 + return rectPixels[j + 3] + }) + close(svgSoft, rectSoft, `${name}: SVG distance matches the procedural rectangle's softness`, 0.04) + samples++ + + at = await render({ size: [0.8, 0.4] }, WIDE) + check("a width/height-only SVG fills its box", at(0.35, 0.15), [0, 0, 0, 1]) + check("outside the wide box", at(0, 0.25), backdrop) + + await pass.setSvg(null) + pass.render(renderer, input, target, 0, 0) + const cleared = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + assert(cleared.every((v) => v === 0), `${name}: no SVG draws nothing`) + samples++ + + await pass.setSvg("data:image/svg+xml,not-an-svg").then( + () => { + throw new Error(`${name}: a missing SVG must reject`) + }, + () => undefined + ) + samples++ + pass.dispose() + } + for (const [label, entry] of Object.entries(results)) + close(entry.editor, entry.runtime, `parity: ${label}`, 0.002) + samples += Object.keys(results).length + } finally { + target.dispose() + input.dispose() + renderer.dispose() + } + return samples +} + +async function runtimeRender(config) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const size = config.composition + const headless = createHeadlessRenderer({ renderer, size }) + const target = new THREE.RenderTarget(size.width, size.height, { type: THREE.FloatType, depthBuffer: false }) + const material = new THREE.MeshBasicNodeMaterial({ blending: THREE.NoBlending }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, size) + let previous = null + for (let attempt = 0; attempt < 40; attempt++) { + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + await new Promise((resolve) => setTimeout(resolve, 100)) + material.colorNode = texture(headless.render(frame), vec2(uv().x, float(1).sub(uv().y))) + material.needsUpdate = true + renderer.setRenderTarget(target) + renderer.render(scene, camera) + const pixels = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, size.width, size.height)) + if (previous && attempt > 4 && pixels.every((value, i) => value === previous[i])) return pixels + previous = pixels + } + return previous + } finally { + headless.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkSvgShapes(renderProject) { + let samples = unitChecks() + samples += await passChecks() + + const logo = { id: "logo", kind: "image", url: TWO_TONE, fileName: "two-tone.svg", width: 100, height: 100 } + const photo = { id: "photo", kind: "image", url: "/scenes/default/rings-photo.webp", fileName: "slice.webp", width: 1512, height: 908 } + const shape = { + ...createLayer("shape"), + id: "svg-shape", + assetId: logo.id, + blendMode: "multiply", + params: { + ...createLayer("shape").params, + shape: "svg", + size: [0.7, 0.7], + rotation: 15, + svgPalette: '{"#0000ff":"#ffcc00"}', + }, + } + const project = { + format: "shader-lab", + version: 7, + assets: [logo, photo], + layers: [shape, { ...createLayer("image"), id: "photo-layer", assetId: photo.id }], + selectedLayerId: shape.id, + composition: { width: N, height: N }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const store = () => useLayerStore.getState() + const before = buildEditorHistorySnapshot() + store().updateLayerParam(shape.id, "svgPalette", "") + store().updateLayerParam(shape.id, "svgColorMode", "single") + applyEditorHistorySnapshot(before) + assert( + store().getLayerById(shape.id).params.svgPalette === '{"#0000ff":"#ffcc00"}' && + store().getLayerById(shape.id).params.svgColorMode === "original", + "History lost SVG colors" + ) + const duplicateId = store().duplicateLayer(shape.id) + assert(store().getLayerById(duplicateId).assetId === logo.id, "Duplicates keep the SVG") + applyEditorHistorySnapshot(before) + const saved = buildLabProjectFile() + assert(saved.assets.some((asset) => asset.id === logo.id), "Saved files keep the SVG asset") + store().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), []) + const reopened = buildLabProjectFile() + const reopenedShape = reopened.layers.find((l) => l.id === shape.id) + assert( + reopenedShape.assetId === logo.id && reopenedShape.params.svgPalette === '{"#0000ff":"#ffcc00"}', + "Save/reopen changed the SVG shape" + ) + const missing = { + ...JSON.parse(JSON.stringify(saved)), + assets: saved.assets.map((asset) => (asset.id === logo.id ? { id: asset.id, kind: "image", fileName: "two-tone.svg" } : asset)), + } + store().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(missing), []) + assert(store().getLayerById(shape.id).runtimeError === "Missing asset: two-tone.svg", "A missing SVG is reported") + samples += 6 + + const config = buildShaderExportConfig(reopened) + const exported = config.layers.find((l) => l.id === shape.id) + assert( + exported.asset?.kind === "image" && exported.asset.src === "/replace/image/two-tone.svg", + `Shader export includes the SVG, got ${JSON.stringify(exported.asset)}` + ) + samples++ + + const first = await renderProject(saved) + const at = (image, x, y) => { + const i = (index(y) * N + index(x)) * 4 + return Array.from(image.data.slice(i, i + 4)) + } + const outside = await renderProject({ ...saved, layers: saved.layers.map((l) => (l.id === shape.id ? { ...l, visible: false } : l)) }) + close(at(first.image, 0.45, 0.45), at(outside.image, 0.45, 0.45), "Outside the SVG the photo is untouched", 1) + const inside = at(first.image, -0.1, 0) + const photoInside = at(outside.image, -0.1, 0) + assert(inside[1] < photoInside[1] - 10 && inside[2] < photoInside[2] - 10, `Multiply with red darkens green and blue, got ${inside} over ${photoInside}`) + const restored = await renderProject(reopened) + close(Array.from(restored.image.data), Array.from(first.image.data), "Reopened pixels", 0) + const runtimePixels = await runtimeRender({ + ...config, + layers: config.layers.map((layer) => { + if (layer.id === shape.id) return { ...layer, asset: { ...layer.asset, src: TWO_TONE } } + if (layer.asset) return { ...layer, asset: { ...layer.asset, src: photo.url } } + return layer + }), + }) + close( + runtimePixels, + Array.from(first.image.data, (value, i) => { + const v = value / 255 + return i % 4 === 3 ? v : linear(v) + }), + "Exported runtime SVG shape parity", + 0.02 + ) + samples += 4 + return { samples, png: first.png } +} diff --git a/tests/composition/text-editing-ui.mjs b/tests/composition/text-editing-ui.mjs new file mode 100644 index 00000000..c1303c18 --- /dev/null +++ b/tests/composition/text-editing-ui.mjs @@ -0,0 +1,172 @@ +// Start the dev server, then bun tests/composition/text-editing-ui.mjs. +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +await mkdir(".context", { recursive: true }) +const base = createLayer("gradient") +await Bun.write( + ".context/text-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: "headline", + assets: [], + layers: [ + { + ...createLayer("text"), + id: "headline", + name: "Headline", + params: { ...createLayer("text").params, text: "Hello", fontSize: 64 }, + }, + { ...base, id: "field", name: "Field", params: { ...base.params, motionStyle: "off" } }, + ], + sceneConfig: { + ...DEFAULT_SCENE_CONFIG, + backgroundColor: "#f5f2ee", + compositionAspect: "custom", + compositionWidth: 720, + compositionHeight: 960, + }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: ["--enable-unsafe-webgpu", "--use-webgpu-adapter=swiftshader", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { waitUntil: "domcontentloaded", timeout: 120000 }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every((loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return true + } + return false + }), + null, + { timeout: 120000 } + ) + } + await ready() + const button = (name) => page.getByRole("button", { name, exact: true }).filter({ visible: true }) + async function save(name) { + await button("Export").click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page.getByRole("button", { name: "Export .lab file", exact: true }).click() + const download = await downloaded + const path = `.context/text-${name}.lab` + await download.saveAs(path) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return await Bun.file(path).json() + } + const headline = (project) => project.layers.find((l) => l.id === "headline").params + await button("Export").click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page.locator('input[accept=".lab,application/json"]').last().setInputFiles(".context/text-ui-fixture.lab") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + await panel.locator('[data-layer-row="headline"]').waitFor() + await ready() + await panel.locator('[data-layer-row="headline"]').getByText("Headline", { exact: true }).click() + + const handles = page.locator('[data-text-handles="true"]') + const stage = await page.locator('[data-artboard="fixed"]').boundingBox() + await page.mouse.move(stage.x + stage.width / 2, stage.y + stage.height / 2) + await handles.waitFor() + const center = page.locator('[data-text-handle="center"]') + const cb = await center.boundingBox() + const ccx = cb.x + cb.width / 2 + const ccy = cb.y + cb.height / 2 + await page.mouse.move(ccx, ccy) + await page.mouse.down() + await page.mouse.move(ccx + 60, ccy + 40, { steps: 6 }) + await page.mouse.move(ccx + 120, ccy + 80, { steps: 6 }) + await page.mouse.up() + await page.waitForTimeout(400) + const moved = headline(await save("moved")) + assert.ok(moved.offset[0] > 0.05 && moved.offset[1] < -0.02, `Center drag moves offset (${moved.offset})`) + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.deepEqual(headline(await save("moved-undo")).offset, [0, 0], "One drag is one undo step") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(400) + + const turn = page.locator('[data-text-handle="rotate-se"]') + const tb = await turn.boundingBox() + await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2) + await page.mouse.down() + await page.mouse.move(tb.x - 20, tb.y + 70, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(400) + const rotated = headline(await save("rotated")) + assert.ok(Math.abs(rotated.rotation) > 5, `Corner zone rotates text (${rotated.rotation})`) + + const corner = page.locator('[data-text-handle="se"]') + const sb = await corner.boundingBox() + await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2) + await page.mouse.down() + await page.mouse.move(sb.x + 40, sb.y + 60, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(400) + const resized = headline(await save("resized")) + assert.ok(resized.fontSize !== 64, `Corner changes font size (${resized.fontSize})`) + + const artboard = page.locator('[data-artboard="fixed"]') + const ab = await artboard.boundingBox() + await page.mouse.dblclick(ab.x + ab.width * 0.15, ab.y + ab.height * 0.85) + const editor = page.getByRole("textbox", { name: "Edit text" }) + await editor.waitFor() + await handles.waitFor({ state: "hidden" }) + await page.keyboard.press("Meta+a") + await page.keyboard.type("Small") + await page.keyboard.press("Enter") + await page.keyboard.type("labels") + await page.waitForTimeout(200) + await page.keyboard.press("Meta+Enter") + await editor.waitFor({ state: "hidden" }) + await handles.waitFor() + await page.waitForTimeout(400) + const typed = headline(await save("typed")) + assert.equal(typed.text, "Small\nlabels", "In-place typing commits multiline text") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.equal(headline(await save("typed-undo")).text, "Hello", "Whole edit is one undo step") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(400) + + await button("Edit text").click() + await editor.waitFor() + await page.keyboard.press("Meta+a") + await page.keyboard.type("discard me") + await page.keyboard.press("Escape") + await editor.waitFor({ state: "hidden" }) + await page.waitForTimeout(400) + assert.equal(headline(await save("escaped")).text, "Small\nlabels", "Escape restores the original text") + + const size = page.getByRole("slider", { name: /^Font Size/ }).filter({ visible: true }) + await size.waitFor() + await size.focus() + await page.keyboard.press("Home") + await page.waitForTimeout(400) + const tiny = headline(await save("tiny")) + assert.equal(tiny.fontSize, 8, `Font Size slider goes down to 8 (${tiny.fontSize})`) + const area = page.getByRole("textbox", { name: /^Text/ }).filter({ visible: true }).first() + assert.equal(await area.evaluate((el) => el.tagName), "TEXTAREA", "Sidebar text field is multiline") + await page.screenshot({ path: ".context/text-editing-ui.png" }) + assert.deepEqual(errors, []) + console.log("PASS text handles move/rotate/resize with undo, double-click in-place editing, Cmd+Enter commit, Escape cancel, sidebar textarea, font size below 48") +} finally { + await browser.close() +} diff --git a/tests/composition/text-editing.mjs b/tests/composition/text-editing.mjs new file mode 100644 index 00000000..e4d7a174 --- /dev/null +++ b/tests/composition/text-editing.mjs @@ -0,0 +1,204 @@ +import { TextPass as RuntimeTextPass } from "@runtime/renderer/text-pass" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + offsetFromPivotUnits, + textPivotUnits, +} from "@/lib/editor/text-geometry" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { splitTextLines } from "@/renderer/text-layout" +import { TextPass } from "@/renderer/text-pass" +import { useLayerStore } from "@/store/layer-store" +import { + canEditTextLayer, + findTextLayerToEdit, +} from "@/store/text-edit-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(condition, message) { + if (!condition) throw new Error(message) +} +const W = 256 +const H = 192 +const base = { + text: "V3", + fontFamily: "mono", + fontWeight: 400, + fontSize: 64, + letterSpacing: 0, + textColor: "#ffffff", + backgroundColor: "#000000", + backgroundAlpha: 0, + anchor: "center", + offset: [0, 0], +} + +function coverage(pixels) { + const rows = new Set() + const cols = new Set() + let count = 0 + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] > 0.5) { + count++ + const p = i / 4 + rows.add(Math.floor(p / W)) + cols.add(p % W) + } + } + const sorted = (set) => [...set].sort((a, b) => a - b) + const r = sorted(rows) + const c = sorted(cols) + return { + count, + top: r[0] ?? -1, + bottom: r.at(-1) ?? -1, + left: c[0] ?? -1, + right: c.at(-1) ?? -1, + rows: r, + } +} + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const input = new THREE.DataTexture(new Float32Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat, THREE.FloatType) + input.needsUpdate = true + const target = new THREE.RenderTarget(W, H, { type: THREE.FloatType, depthBuffer: false }) + const renders = {} + let samples = 0 + try { + for (const [name, Pass] of [ + ["editor", TextPass], + ["runtime", RuntimeTextPass], + ]) { + const pass = new Pass(`${name}-text`) + pass.resize(W, H) + pass.updateLogicalSize(W, H) + pass.updateCompositionRole("source") + pass.flushColorNode() + const paint = async (params) => { + pass.updateParams({ ...base, ...params }) + pass.updateOpacity(1) + pass.render(renderer, input, target, 0, 0) + return Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, W, H)) + } + const record = (label, pixels) => { + renders[label] ??= {} + renders[label][name] = pixels + samples++ + return coverage(pixels) + } + const legacy = record("legacy single line", await paint({})) + const explicit = record( + "explicit defaults", + await paint({ align: "auto", lineHeight: 1.1, rotation: 0 }) + ) + assert( + legacy.count > 50 && explicit.count === legacy.count && explicit.top === legacy.top, + `${name}: default multiline params must not change single-line output` + ) + const two = record("two lines", await paint({ text: "V3\nV3" })) + assert(two.count > legacy.count * 1.6, `${name}: second line adds glyphs (${two.count} vs ${legacy.count})`) + assert(two.top < legacy.top && two.bottom > legacy.bottom, `${name}: two lines extend above and below`) + const gaps = two.rows.some((row, i) => i > 0 && row - two.rows[i - 1] > 3) + assert(gaps, `${name}: lines are separated by a gap`) + const tight = record("tight line height", await paint({ text: "V3\nV3", lineHeight: 0.7 })) + assert(tight.bottom - tight.top < two.bottom - two.top, `${name}: line height shrinks the block`) + const top = record("top anchor two lines", await paint({ text: "V3\nV3", anchor: "top-center" })) + assert(top.top <= 2 && top.bottom < H * 0.7, `${name}: top-anchored block starts at the top edge (${top.top}, ${top.bottom})`) + const bottom = record("bottom anchor two lines", await paint({ text: "V3\nV3", anchor: "bottom-center" })) + assert(bottom.bottom >= H - 3 && bottom.top > H * 0.3, `${name}: bottom-anchored block ends at the bottom edge (${bottom.top}, ${bottom.bottom})`) + const left = record("align left", await paint({ text: "V3\nV3V3", align: "left", anchor: "center-left" })) + const ragged = record("ragged center", await paint({ text: "V3\nV3V3", anchor: "center" })) + assert(left.left <= 2, `${name}: left alignment starts at the left edge`) + assert(ragged.left > 20, `${name}: centered ragged lines are centered`) + const forcedLeft = record("forced left at center anchor", await paint({ text: "V3\nV3V3", align: "left" })) + assert(forcedLeft.left > ragged.left + 20 && Math.abs(forcedLeft.left - W / 2) < 10, `${name}: explicit Align overrides the anchor's horizontal (${forcedLeft.left} vs ragged ${ragged.left})`) + const rotated = record("rotated", await paint({ rotation: 90 })) + assert( + rotated.bottom - rotated.top > legacy.bottom - legacy.top + 10 && + rotated.right - rotated.left < legacy.right - legacy.left - 10, + `${name}: 90° rotation swaps the glyph extents` + ) + const centered = Math.abs((rotated.left + rotated.right) / 2 - W / 2) < 6 && Math.abs((rotated.top + rotated.bottom) / 2 - H / 2) < 6 + assert(centered, `${name}: rotation pivots around the anchor`) + const small = record("small font", await paint({ fontSize: 12 })) + assert(small.count > 10 && small.bottom - small.top < 16, `${name}: 12px text renders below the old 48px minimum (${small.bottom - small.top})`) + pass.dispose() + } + for (const [label, entry] of Object.entries(renders)) { + assert(entry.editor.length === entry.runtime.length, `parity size: ${label}`) + let diff = 0 + for (let i = 0; i < entry.editor.length; i++) diff = Math.max(diff, Math.abs(entry.editor[i] - entry.runtime[i])) + assert(diff < 0.02, `parity: ${label} differs by ${diff}`) + samples++ + } + } finally { + input.dispose() + target.dispose() + renderer.dispose() + } + return samples +} + +export async function checkTextEditing(renderProject) { + assert(splitTextLines("a\r\nb\nc").length === 3, "Line splitting") + const logical = { width: 1920, height: 1080 } + const params = { ...createLayer("text").params, anchor: "top-left", offset: [0.25, -0.5] } + const units = textPivotUnits(params, logical) + const back = offsetFromPivotUnits(params, logical, units) + assert(Math.abs(back[0] - 0.25) < 1e-3 && Math.abs(back[1] + 0.5) < 1e-3, `Offset roundtrip (${back})`) + const centered = textPivotUnits({ ...params, anchor: "center", offset: [0, 0] }, logical) + assert(Math.abs(centered[0]) < 1e-9 && Math.abs(centered[1]) < 1e-9, "Center anchor pivots at origin") + const upward = textPivotUnits({ ...params, anchor: "center", offset: [0, 0.25] }, logical) + assert(upward[1] < 0, "Positive offset y moves up (negative units)") + const fresh = createLayer("text") + assert(fresh.params.align === "auto" && fresh.params.lineHeight === 1.1 && fresh.params.rotation === 0, "New text defaults") + let samples = 5 + samples += await passChecks() + + const text = { ...createLayer("text"), id: "headline", params: { ...createLayer("text").params, ...base, text: "Line one\nLine two", rotation: -12, align: "left", fontSize: 20 } } + const locked = { ...createLayer("text"), id: "locked", locked: true } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [locked, text], + selectedLayerId: null, + composition: { width: W, height: H }, + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#2468cc" }, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const layers = useLayerStore.getState().layers + assert(!canEditTextLayer(layers, "locked"), "Locked text is not editable") + assert(findTextLayerToEdit(layers, null) === "headline", "Double-click picks the first editable text layer") + assert(findTextLayerToEdit(layers, "headline") === "headline", "Selected text wins") + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), []) + const reopened = buildLabProjectFile() + const restored = reopened.layers.find((l) => l.id === "headline") + assert( + restored.params.text === "Line one\nLine two" && restored.params.rotation === -12 && restored.params.fontSize === 20 && restored.params.align === "left", + "Save/reopen lost multiline text settings" + ) + const config = buildShaderExportConfig(reopened) + assert(config.layers.find((l) => l.id === "headline").params.text.includes("\n"), "Export keeps newlines") + const first = await renderProject(saved) + const again = await renderProject(reopened) + let diff = 0 + for (let i = 0; i < first.image.data.length; i++) diff = Math.max(diff, Math.abs(first.image.data[i] - again.image.data[i])) + assert(diff === 0, "Reopened text pixels") + let lit = 0 + for (let i = 0; i < first.image.data.length; i += 4) if (first.image.data[i] > 200 && first.image.data[i + 1] > 200) lit++ + assert(lit > 40, "Rendered small rotated multiline text is visible") + samples += 6 + return { samples, png: first.png } +} diff --git a/tests/composition/threshold-colors-ui.mjs b/tests/composition/threshold-colors-ui.mjs new file mode 100644 index 00000000..ddd18b35 --- /dev/null +++ b/tests/composition/threshold-colors-ui.mjs @@ -0,0 +1,169 @@ +// Start the dev server, then bun tests/composition/threshold-colors-ui.mjs. +import assert from "node:assert/strict" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +import { mkdir } from "node:fs/promises" +await mkdir(".context", { recursive: true }) +const group = { + ...createLayer("group"), + id: "group", + name: "Photographic study", +} +const threshold = { + ...createLayer("threshold"), + id: "threshold", + parentId: group.id, +} +const photo = { ...createLayer("image"), id: "photo-layer", parentId: group.id } +await Bun.write( + ".context/threshold-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: threshold.id, + assets: [], + layers: [group, threshold, photo], + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#f5f2ee" }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await ready() + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(".context/threshold-ui-fixture.lab") + await panel.locator('[data-layer-row="threshold"]').waitFor() + await ready() + await panel + .locator('[data-layer-row="photo-layer"]') + .getByText("Image", { exact: true }) + .click() + const chooser = page.waitForEvent("filechooser") + const add = panel.getByRole("button", { name: "Add layer", exact: true }) + await add.click() + const menu = await add.getAttribute("aria-controls") + await page + .locator(`[id="${menu}"]`) + .getByRole("button", { name: /^Image$/ }) + .click() + await (await chooser).setFiles("public/examples/effects/slice.webp") + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-layer-sidebar-panel="true"] [data-layer-row]' + ).length >= 4 + ) + await panel + .locator('[data-layer-row="photo-layer"]') + .getByRole("button", { name: "Delete Image", exact: true }) + .click() + await panel + .locator('[data-layer-row="threshold"]') + .getByText("Threshold", { exact: true }) + .click() + await ready() + + await page.getByText("Dark Color", { exact: true }).first().waitFor() + async function color(from, to, _label) { + await page.getByRole("button", { name: from, exact: true }).click() + await page.locator("[data-color-picker-popup] input").fill(to) + await page.keyboard.press("Escape") + await page.locator("[data-color-picker-popup]").waitFor({ state: "hidden" }) + await page.waitForTimeout(350) + } + async function save(name) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const download = await downloaded + const file = `.context/threshold-${name}.lab` + await download.saveAs(file) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return (await Bun.file(file).json()).layers.find( + (l) => l.id === "threshold" + ).params + } + await color("#000000", "#162FC4", "Dark Color") + await color("#FFFFFF", "#F04E9B", "Light Color") + const chosen = await save("chosen") + assert.equal(chosen.darkColor, "#162FC4") + assert.equal(chosen.lightColor, "#F04E9B") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(350) + assert.equal((await save("undo")).lightColor.toLowerCase(), "#ffffff") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(350) + assert.deepEqual(await save("redo"), chosen) + await page.screenshot({ path: ".context/threshold-editor-ui.png" }) + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(".context/threshold-chosen.lab") + await ready() + assert.deepEqual(await save("reopened"), chosen) + assert.deepEqual(errors, []) + console.log( + "PASS Threshold color controls, undo/redo, actual .lab save and hydration" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/threshold-colors.mjs b/tests/composition/threshold-colors.mjs new file mode 100644 index 00000000..0bc9cb67 --- /dev/null +++ b/tests/composition/threshold-colors.mjs @@ -0,0 +1,362 @@ +import * as THREE from "three/webgpu" +import { ThresholdPass } from "@/renderer/threshold-pass" +import { ThresholdPass as RuntimeThreshold } from "@runtime/renderer/threshold-pass" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { + buildEditorHistorySnapshot, + applyEditorHistorySnapshot, +} from "@/lib/editor/history" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +const assert = { + ok(value, message) { + if (!value) throw new Error(message) + }, + equal(actual, expected, message) { + if (actual !== expected) + throw new Error(`${message}: ${actual} != ${expected}`) + }, +} +function close(actual, expected, label, epsilon = 0.0001) { + assert.equal(actual.length, expected.length, label) + actual.forEach((v, i) => { + assert.ok( + Number.isFinite(v) && Math.abs(v - expected[i]) <= epsilon, + `${label} channel ${i}: ${v} != ${expected[i]}` + ) + }) +} +// Capture two presented frames once, so editor/runtime see the identical video +// input regardless of shader compilation timing. No paused-seek timing assumption. +async function decodeVideoFrames() { + const video = document.createElement("video") + video.src = "/scenes/default/aura.mp4" + video.muted = true + video.loop = true + const canvas = document.createElement("canvas") + canvas.width = 64 + canvas.height = 64 + const context = canvas.getContext("2d") + const inputs = [] + try { + await video.play() + for (let frame = 0; frame <= 12; frame++) { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Video frame timed out")), + 8000 + ) + video.requestVideoFrameCallback(() => { + clearTimeout(timeout) + resolve() + }) + }) + if (frame === 0 || frame === 12) { + context.drawImage(video, 0, 0, 64, 64) + const input = new THREE.DataTexture( + new Uint8Array(context.getImageData(0, 0, 64, 64).data), + 64, + 64, + THREE.RGBAFormat + ) + input.needsUpdate = true + inputs.push(input) + } + } + return inputs + } catch (error) { + for (const input of inputs) input.dispose() + throw error + } finally { + video.pause() + video.removeAttribute("src") + video.load() + } +} +export async function checkThresholdColors(renderProject) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const data = new Float32Array(64 * 4) + for (let x = 0; x < 64; x++) + data.set( + [x / 63, x / 63, x / 63, [0, 0.25, 0.6, 1][Math.floor(x / 16)]], + x * 4 + ) + const source = new THREE.DataTexture( + data, + 64, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + source.needsUpdate = true + const target = new THREE.RenderTarget(64, 1, { + type: THREE.FloatType, + depthBuffer: false, + }) + const expected = new Map() + let samples = 0 + const videoInputs = [] + try { + videoInputs.push(...(await decodeVideoFrames())) + for (const [name, Pass] of [ + ["editor", ThresholdPass], + ["runtime", RuntimeThreshold], + ]) { + const pass = new Pass(`threshold-${name}`) + pass.updateCompositionRole("effect") + pass.flushColorNode() + pass.updateLogicalSize(64, 1) + const node = pass.material.colorNode + const render = async (label, params, input = source, opacity = 1) => { + pass.updateOpacity(opacity) + pass.updateParams(params) + pass.render(renderer, input, target, 0, 0) + assert.equal( + pass.material.colorNode, + node, + "Palette change rebuilt the material" + ) + const pixels = Array.from( + await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 64, + target.height + ) + ) + if (name === "editor") expected.set(label, pixels) + else close(pixels, expected.get(label), `Runtime parity ${label}`, 0) + samples++ + return pixels + } + try { + const base = { threshold: 0.5, softness: 0.1, noise: 0 } + const legacy = await render("legacy defaults", base) + for (let x = 0; x < 64; x++) { + const t = Math.max(0, Math.min(1, (x / 63 - 0.4) / 0.2)) + const weight = t * t * (3 - 2 * t) + close( + legacy.slice(x * 4, x * 4 + 4), + [weight, weight, weight, data[x * 4 + 3]], + "Original threshold and alpha" + ) + } + const palette = { darkColor: "#162fc4", lightColor: "#f04e9b" } + const dark = new THREE.Color(palette.darkColor).toArray() + const light = new THREE.Color(palette.lightColor).toArray() + const colored = await render("custom colors", { ...base, ...palette }) + for (let x = 0; x < 64; x++) + close( + colored.slice(x * 4, x * 4 + 4), + [ + ...dark.map((v, c) => v + (light[c] - v) * legacy[x * 4]), + data[x * 4 + 3], + ], + "Palette endpoints, soft blend and source alpha" + ) + const inverted = await render("inverted colors", { + ...base, + ...palette, + invert: true, + }) + for (let x = 0; x < 64; x++) + close( + inverted.slice(x * 4, x * 4 + 4), + [ + ...dark.map((v, c) => v + (light[c] - v) * (1 - legacy[x * 4])), + data[x * 4 + 3], + ], + "Invert swaps region assignment" + ) + const noise = await render("noise legacy", { ...base, noise: 0.15 }) + const noisyColors = await render("noise colors", { + ...base, + ...palette, + noise: 0.15, + }) + for (let x = 0; x < 64; x++) + close( + noisyColors.slice(x * 4, x * 4 + 3), + dark.map((v, c) => v + (light[c] - v) * noise[x * 4]), + "Palette moved noisy boundaries" + ) + const faded = await render( + "opacity", + { ...base, ...palette }, + source, + 0.4 + ) + for (let x = 0; x < 64; x++) + close( + faded.slice(x * 4, x * 4 + 4), + [ + ...dark.map( + (_, c) => data[x * 4 + c] * 0.6 + colored[x * 4 + c] * 0.4 + ), + data[x * 4 + 3], + ], + "Effect opacity" + ) + close( + await render("reset missing colors", base), + legacy, + "Missing color fallbacks" + ) + close( + await render("explicit black white", { + ...base, + darkColor: "#000000", + lightColor: "#ffffff", + }), + legacy, + "Black white compatibility" + ) + for (const threshold of [0, 0.5, 1]) + for (const invert of [false, true]) { + const bw = await render(`hard-${threshold}-${invert}`, { + threshold, + softness: 0, + noise: 0, + invert, + }) + const colors = await render(`hard-colors-${threshold}-${invert}`, { + threshold, + softness: 0, + noise: 0, + invert, + ...palette, + }) + for (let x = 0; x < 64; x++) + close( + colors.slice(x * 4, x * 4 + 3), + dark.map((v, c) => v + (light[c] - v) * bw[x * 4]), + "Hard threshold" + ) + } + target.setSize(64, 64) + pass.updateLogicalSize(64, 64) + const frames = [] + for (const [index, input] of videoInputs.entries()) { + frames.push( + await render( + `video-${index}`, + { ...base, ...palette, threshold: 0.2, softness: 0.2 }, + input + ) + ) + } + assert.ok( + frames[0].some((v, i) => Math.abs(v - frames[1][i]) > 0.01), + "Threshold did not update with the decoded video" + ) + } finally { + pass.dispose() + target.setSize(64, 1) + } + } + } finally { + for (const input of videoInputs) input.dispose() + source.dispose() + target.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } + + const layer = { + ...createLayer("threshold"), + id: "threshold", + params: { + threshold: 0.4, + softness: 0.02, + noise: 0.08, + darkColor: "#1425a8", + lightColor: "#ff593c", + }, + } + const photo = { ...createLayer("image"), id: "photo", assetId: "sample" } + const project = { + format: "shader-lab", + version: 7, + composition: { width: 512, height: 384 }, + assets: [ + { + id: "sample", + kind: "image", + fileName: "photo.webp", + url: "/scenes/default/rings-photo.webp", + width: 1512, + height: 982, + }, + ], + layers: [layer, photo], + selectedLayerId: layer.id, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 2, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const snapshot = buildEditorHistorySnapshot() + useLayerStore.getState().updateLayerParam(layer.id, "darkColor", "#000000") + applyEditorHistorySnapshot(snapshot) + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const hydrated = useLayerStore.getState().getLayerById(layer.id) + assert.equal( + hydrated.params.darkColor, + layer.params.darkColor, + "History/hydration lost dark color" + ) + assert.equal( + hydrated.params.lightColor, + layer.params.lightColor, + "Hydration lost light color" + ) + const exported = buildShaderExportConfig(saved) + const frame = runtimeFrame(exported, 0, 0, 1, exported.composition) + assert.equal( + frame.layers.find((l) => l.id === layer.id).params.darkColor, + layer.params.darkColor, + "Runtime export lost color" + ) + const rendered = await renderProject(saved) + const reopened = await renderProject(buildLabProjectFile()) + close( + Array.from(rendered.image.data), + Array.from(reopened.image.data), + "Reopened PNG", + 0 + ) + const old = { + ...project, + layers: [ + { ...layer, params: { threshold: 0.4, softness: 0.02, noise: 0.08 } }, + photo, + ], + } + applyLabProjectFile(parseLabProjectFileValue(old), []) + assert.equal( + useLayerStore.getState().getLayerById(layer.id).params.darkColor, + "#000000", + "Legacy dark default" + ) + assert.equal( + useLayerStore.getState().getLayerById(layer.id).params.lightColor, + "#ffffff", + "Legacy light default" + ) + return { samples, png: rendered.png } +} diff --git a/tests/composition/transparent-text.mjs b/tests/composition/transparent-text.mjs new file mode 100644 index 00000000..f0291020 --- /dev/null +++ b/tests/composition/transparent-text.mjs @@ -0,0 +1,239 @@ +import { TextPass as RuntimeTextPass } from "@runtime/renderer/text-pass" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { TextPass } from "@/renderer/text-pass" +import { useLayerStore } from "@/store/layer-store" + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function close(actual, expected, label, tolerance = 0.002) { + assert( + actual.length === expected.length && + actual.every( + (value, i) => + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance + ), + `${label}: expected ${expected}, received ${actual}` + ) +} + +async function checkTextPasses(layer, exportedLayer) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0, 0, 1, 0.4] + const input = new THREE.DataTexture( + new Float32Array(backdrop), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + const target = new THREE.RenderTarget(256, 192, { + type: THREE.FloatType, + depthBuffer: false, + }) + let samples = 0 + try { + for (const [name, Pass, config] of [ + ["editor", TextPass, layer], + ["runtime", RuntimeTextPass, exportedLayer], + ]) { + const pass = new Pass(`${name}-text`) + try { + pass.resize(256, 192) + pass.updateLogicalSize(256, 192) + pass.updateCompositionRole(config.kind) + pass.updateCompositeMode(config.compositeMode) + pass.updateBlendMode(config.blendMode) + pass.flushColorNode() + const paint = async (params, opacity = 1) => { + pass.updateParams(params) + pass.updateOpacity(opacity) + pass.render(renderer, input, target, 0, 0) + return Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, 256, 192) + ) + } + const transparent = await paint(config.params) + close( + transparent.slice(0, 4), + backdrop, + `${name}: transparent background preserves underlying alpha` + ) + let glyph = -1 + let softEdge = -1 + for (let i = 0; i < transparent.length; i += 4) { + if (transparent[i] > 0.999 && transparent[i + 1] > 0.999) glyph = i + if (transparent[i + 3] > 0.45 && transparent[i + 3] < 0.95) + softEdge = i + } + assert(glyph >= 0, `${name}: no fully opaque lettering`) + close( + transparent.slice(glyph, glyph + 4), + [1, 1, 1, 1], + `${name}: white glyph is opaque` + ) + assert(softEdge >= 0, `${name}: missing antialiased glyph edges`) + // White over blue always retains blue=1, even along soft alpha edges. + close([transparent[softEdge + 2]], [1], `${name}: no dark fringe`) + const colored = await paint({ ...config.params, textColor: "#ff0000" }) + close( + colored.slice(glyph, glyph + 4), + [1, 0, 0, 1], + `${name}: glyph color is editable` + ) + close( + colored.slice(0, 4), + backdrop, + `${name}: color leaves empty regions untouched` + ) + const solid = await paint({ ...config.params, backgroundAlpha: 1 }) + close( + solid.slice(0, 4), + [0, 0, 0, 1], + `${name}: deliberate solid background` + ) + const faded = await paint(config.params, 0.5) + close( + faded.slice(0, 4), + backdrop, + `${name}: opacity does not fill empty regions` + ) + close( + faded.slice(glyph, glyph + 4), + [5 / 7, 5 / 7, 1, 0.7], + `${name}: glyph opacity composites correctly` + ) + const legacyParams = { ...config.params } + delete legacyParams.backgroundAlpha + const legacy = await paint(legacyParams) + close( + legacy.slice(0, 4), + [0, 0, 0, 1], + `${name}: absent runtime parameter retains solid fallback` + ) + samples += 9 + } finally { + pass.dispose() + } + } + } finally { + input.dispose() + target.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } + return samples +} + +export async function checkTransparentText(renderProject) { + const fixture = await ( + await fetch("/fixtures/solid-text-background.json") + ).json() + const text = createLayer("text") + assert( + text.compositeMode === "filter" && + text.blendMode === "normal" && + text.opacity === 1 && + text.params.backgroundAlpha === 0, + "New text requires a mask, a blend workaround, or an opaque background" + ) + + // Exercise real editor hydration, including files that relied on defaults. + for (const version of [1, 6, 7]) { + for (const alpha of [undefined, 0, 0.35, 1]) { + for (const mode of ["filter", "mask"]) { + const old = structuredClone(fixture) + old.version = version + old.layers[0].compositeMode = mode + old.layers[0].blendMode = "screen" + if (alpha === undefined) delete old.layers[0].params.backgroundAlpha + else old.layers[0].params.backgroundAlpha = alpha + applyLabProjectFile(parseLabProjectFileValue(old), []) + const restored = useLayerStore.getState().layers[0] + assert( + restored.params.backgroundAlpha === (alpha ?? 1) && + restored.compositeMode === mode && + restored.blendMode === "screen", + "Hydration changed saved text opacity, mask mode, or blend mode" + ) + } + } + } + + // Use a bundled deterministic font; leave new composition/alpha defaults intact. + text.params = { + ...text.params, + text: "V3", + fontFamily: "mono", + fontWeight: 400, + fontSize: 96, + letterSpacing: 0, + } + const project = { + ...fixture, + layers: [text], + assets: [], + selectedLayerId: text.id, + sceneConfig: { ...fixture.sceneConfig, backgroundColor: "#2468cc" }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const reopened = buildLabProjectFile() + assert( + reopened.layers[0].params.backgroundAlpha === 0 && + reopened.layers[0].compositeMode === "filter", + "Save/reopen lost transparent text" + ) + const config = buildShaderExportConfig({ + ...reopened, + timeline: reopened.timeline, + }) + assert( + config.layers[0].params.backgroundAlpha === 0 && + config.layers[0].compositeMode === "filter", + "Shader export lost transparent text" + ) + const transparent = await renderProject(saved) + close( + Array.from(transparent.image.data.slice(0, 4)), + [36, 104, 204, 255], + "Preview/PNG reveal background", + 1 + ) + const restored = await renderProject(reopened) + close( + Array.from(restored.image.data), + Array.from(transparent.image.data), + "Saved/reopened text pixels", + 0 + ) + const solidFile = structuredClone(saved) + solidFile.layers[0].params.backgroundAlpha = 1 + const solid = await renderProject(solidFile) + close( + Array.from(solid.image.data.slice(0, 4)), + [0, 0, 0, 255], + "Optional solid background in preview/PNG", + 0 + ) + return { + samples: await checkTextPasses(reopened.layers[0], config.layers[0]), + png: transparent.png, + } +} diff --git a/tests/composition/tsconfig.json b/tests/composition/tsconfig.json new file mode 100644 index 00000000..f5e2740b --- /dev/null +++ b/tests/composition/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["../../src/*"], + "@runtime/*": ["../../packages/shader-lab-react/src/*"] + } + } +} diff --git a/tests/composition/wgsl-capture.mjs b/tests/composition/wgsl-capture.mjs new file mode 100644 index 00000000..80efabae --- /dev/null +++ b/tests/composition/wgsl-capture.mjs @@ -0,0 +1,132 @@ +import { createHash } from "node:crypto" +import { mkdir, readdir, rm } from "node:fs/promises" +import { resolve, sep } from "node:path" +import { chromium } from "playwright" + +const root = resolve(import.meta.dir, "../..") +const directory = resolve(import.meta.dir) +const threeDir = process.env.THREE_DIR ? resolve(process.env.THREE_DIR) : null +const label = process.env.LABEL ?? "current" +const only = process.env.CHECKS ? new Set(process.env.CHECKS.split(",")) : null +const output = resolve(root, ".context/wgsl", label) +await rm(output, { force: true, recursive: true }) +await mkdir(output, { recursive: true }) + +const entries = { "": "build/three.module.js", "/tsl": "build/three.tsl.js", "/webgpu": "build/three.webgpu.js" } +const plugins = threeDir + ? [ + { + name: "three-version", + setup(build) { + build.onResolve({ filter: /^three(\/.*)?$/ }, (args) => { + const subpath = args.path.slice("three".length) + if (subpath in entries) return { path: resolve(threeDir, entries[subpath]) } + return { path: resolve(threeDir, subpath.replace(/^\/addons\//, "/examples/jsm/").replace(/^\//, "")) } + }) + }, + }, + ] + : [] +const bundle = await Bun.build({ + define: { "process.env": "{}" }, + entrypoints: [resolve(directory, "browser.mjs")], + plugins, + target: "browser", +}) +if (!bundle.success) throw new AggregateError(bundle.logs, "Composition harness failed to bundle") + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname + if (path === "/") + return new Response( + ``, + { headers: { "Content-Type": "text/html" } } + ) + if (path === "/browser.js") + return new Response(bundle.outputs[0], { headers: { "Content-Type": "text/javascript" } }) + if (path === "/scenes/default/alpha-sample.svg") + return new Response(Bun.file(resolve(directory, "fixtures/alpha-sample.svg"))) + if (path === "/scenes/default/dof-study.png" || path === "/scenes/default/dof-study-depth.png") + return new Response(Bun.file(resolve(directory, `fixtures/${path.split("/").pop()}`))) + if (path === "/scenes/default/rings-photo.webp") + return new Response(Bun.file(resolve(root, "public/examples/effects/slice.webp"))) + if (/^\/scenes\/default\/(motif|shape)-[a-z-]+\.svg$/.test(path)) + return new Response(Bun.file(resolve(directory, `fixtures/${path.split("/").pop()}`))) + const base = path.startsWith("/fixtures/") || path.startsWith("/baselines/") ? directory : resolve(root, "public") + const file = resolve(base, `.${path}`) + if (!(file.startsWith(`${base}${sep}`) && (await Bun.file(file).exists()))) + return new Response("Not found", { status: 404 }) + return new Response(Bun.file(file)) + }, +}) + +const projects = (await readdir(resolve(directory, "baselines"))) + .filter((name) => name.endsWith(".png")) + .map((name) => name.replace(/\.png$/, "")) +const browser = await chromium.launch({ + args: ["--enable-unsafe-webgpu", "--use-webgpu-adapter=swiftshader", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"], + headless: true, +}) +const seen = new Set() +let failures = 0 +try { + const page = await browser.newPage() + page.setDefaultTimeout(600_000) + await page.addInitScript(() => { + window.__capturedWgsl = [] + const create = GPUDevice.prototype.createShaderModule + GPUDevice.prototype.createShaderModule = function (descriptor) { + window.__capturedWgsl.push(descriptor.code) + return create.call(this, descriptor) + } + }) + await page.goto(server.url.href) + await page.waitForFunction(() => typeof window.checkProject === "function") + await page.evaluate(() => document.fonts.load('400 96px "Fixture Mono"')) + const save = async (name) => { + const codes = await page.evaluate(() => window.__capturedWgsl.splice(0)) + let added = 0 + for (const code of codes) { + const hash = createHash("sha1").update(code).digest("hex").slice(0, 12) + if (seen.has(hash)) continue + seen.add(hash) + added += 1 + await Bun.write(resolve(output, `${name}-${hash}.wgsl`), code) + } + return added + } + const run = async (name, action) => { + let status = "ok" + try { + await action() + } catch (error) { + failures += 1 + status = `FAIL ${String(error?.message ?? error).split("\n")[0].slice(0, 200)}` + } + console.log(`${name}: ${status}, ${await save(name)} new shaders`) + } + if (!only) { + for (const project of projects) { + await run(`project-${project}`, () => + page.evaluate((name) => window.checkProject(name, false), project) + ) + } + } + const checks = await page.evaluate(() => + Object.keys(window).filter( + (key) => key.startsWith("check") && key !== "checkProject" && typeof window[key] === "function" + ) + ) + for (const check of checks) { + if (only && !only.has(check)) continue + await run(check, () => page.evaluate((name) => window[name](), check)) + } + console.log(`${seen.size} shaders in ${output}`) +} finally { + await browser.close() + server.stop(true) +} +process.exit(failures > 0 ? 1 : 0) diff --git a/tests/composition/wgsl-scan.mjs b/tests/composition/wgsl-scan.mjs new file mode 100644 index 00000000..e98c9543 --- /dev/null +++ b/tests/composition/wgsl-scan.mjs @@ -0,0 +1,114 @@ +import { readdirSync, readFileSync } from "node:fs" +import { resolve } from "node:path" + +const VARIABLE = /\bnodeVar\d+\b/g +const ASSIGNMENT = /^\s*(nodeVar\d+)\s*=\s*(.*);\s*$/ +const DECLARATION = /^\s*var\s+(nodeVar\d+)\s*:\s*[^=;]+(=\s*(.*))?;\s*$/ + +function uninitializedReads(text) { + const flags = [] + let stack = null + let fn = null + const known = (name) => stack.some((block) => block.assigned.has(name)) + const check = (source, number, line) => { + for (const name of source.match(VARIABLE) ?? []) { + if (!known(name)) flags.push({ code: line, fn, line: number, name }) + } + } + text.split("\n").forEach((raw, index) => { + const number = index + 1 + const line = raw.trim() + if (stack === null) { + const start = /^fn\s+(\w+)/.exec(line) + if (start && line.endsWith("{")) { + fn = start[1] + stack = [{ assigned: new Set(), chain: null, kind: "fn" }] + } + return + } + if (line.startsWith("}")) { + const rest = line.slice(1).trim() + const closed = stack.pop() + if (closed.chain) { + closed.chain.arms.push(closed.assigned) + if (closed.kind === "else" && stack.length > 0) { + const [first, ...others] = closed.chain.arms + for (const name of first) { + if (others.every((arm) => arm.has(name))) stack.at(-1).assigned.add(name) + } + } + } + if (stack.length === 0) { + stack = null + fn = null + return + } + if (rest.startsWith("else if")) { + check(rest, number, line) + stack.push({ assigned: new Set(), chain: closed.chain, kind: "elif" }) + } else if (rest.startsWith("else")) { + stack.push({ assigned: new Set(), chain: closed.chain, kind: "else" }) + } + return + } + const declaration = DECLARATION.exec(line) + if (declaration) { + if (declaration[2]) { + check(declaration[3] ?? "", number, line) + stack.at(-1).assigned.add(declaration[1]) + } + return + } + const assignment = ASSIGNMENT.exec(line) + check(assignment ? assignment[2] : line, number, line) + if (assignment) stack.at(-1).assigned.add(assignment[1]) + if (line.endsWith("{")) { + if (line.startsWith("if")) { + stack.push({ assigned: new Set(), chain: { arms: [] }, kind: "if" }) + } else { + stack.push({ assigned: new Set(), chain: null, kind: /^(for|loop|while)\b/.test(line) ? "loop" : "block" }) + } + } + }) + return flags +} + +function scan(directory) { + const byCheck = new Map() + for (const file of readdirSync(directory).filter((name) => name.endsWith(".wgsl")).sort()) { + const check = file.split("-")[0] + const patterns = byCheck.get(check) ?? new Map() + byCheck.set(check, patterns) + const seen = new Set() + for (const flag of uninitializedReads(readFileSync(resolve(directory, file), "utf8"))) { + if (seen.has(`${flag.fn}:${flag.name}`)) continue + seen.add(`${flag.fn}:${flag.name}`) + const pattern = `${flag.fn}: ${flag.code.replace(/nodeVar\d+/g, "V").replace(/nodeUniform\d+/g, "U").slice(0, 140)}` + if (!patterns.has(pattern)) patterns.set(pattern, `${file}:${flag.line} ${flag.name}`) + } + } + return byCheck +} + +const [candidateDir, baselineDir] = process.argv.slice(2) +if (!candidateDir) { + console.error("Usage: bun tests/composition/wgsl-scan.mjs [baseline-dir]") + process.exit(2) +} +const candidate = scan(resolve(candidateDir)) +const baseline = baselineDir ? scan(resolve(baselineDir)) : new Map() +let added = 0 +for (const [check, patterns] of candidate) { + const known = baseline.get(check) ?? new Map() + const fresh = [...patterns].filter(([pattern]) => !known.has(pattern)) + if (fresh.length === 0) continue + added += fresh.length + console.log(`${check}: ${fresh.length} ${baselineDir ? "new " : ""}uninitialized read patterns`) + for (const [pattern, where] of fresh) console.log(` ${where}\n ${pattern}`) +} +console.log( + added === 0 + ? `No ${baselineDir ? "new " : ""}uninitialized reads.` + : `${added} ${baselineDir ? "new " : ""}uninitialized read patterns.` +) +process.exit(added > 0 ? 1 : 0)