) => {
+ const active = drag.current
+ if (!active || event.pointerId !== active.pointer) return
+ drag.current = null
+ useEditorStore.getState().endInteractiveEdit()
+ }
+ const pickColor = (index: number) => {
+ setEditing(index)
+ const input = colorInput.current
+ if (!input) return
+ input.value = readPoint(params, index).color.slice(0, 7)
+ input.click()
+ }
+
+ return (
+
+
+ {POINT_NAMES.slice(0, count).map((name, index) => {
+ const point = readPoint(params, index)
+ const [x, y] = toPx(point.position)
+ const reach = 10 + point.weight * 8
+ return (
+
+
+ {
+ event.stopPropagation()
+ pickColor(index)
+ }}
+ onLostPointerCapture={end}
+ onPointerCancel={end}
+ onPointerDown={begin(index)}
+ onPointerMove={move}
+ onPointerUp={end}
+ r={9}
+ stroke="white"
+ strokeWidth={2.5}
+ style={{
+ cursor: panning ? "inherit" : "grab",
+ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.45))",
+ pointerEvents: "all",
+ }}
+ />
+
+ )
+ })}
+
+ {
+ if (editing === null) return
+ write(`point${editing + 1}Color`, event.currentTarget.value)
+ }}
+ ref={colorInput}
+ style={
+ editing === null
+ ? undefined
+ : {
+ left: toPx(readPoint(params, editing).position)[0],
+ top: toPx(readPoint(params, editing).position)[1],
+ }
+ }
+ tabIndex={-1}
+ type="color"
+ />
+
+ )
+}
diff --git a/src/components/editor/properties-sidebar-content.tsx b/src/components/editor/properties-sidebar-content.tsx
index cd599ab3..565a6766 100644
--- a/src/components/editor/properties-sidebar-content.tsx
+++ b/src/components/editor/properties-sidebar-content.tsx
@@ -1013,8 +1013,9 @@ export function SelectedLayerPropertiesContent({
- Randomize all points, distortion, animation, and finish
- settings.
+ New palette and point layout. Distortion, motion and finish
+ stay. Drag the color points on the canvas; double-click one to
+ change its color, Alt-drag to change its reach.
randomizeGradientParams(layerId)}
@@ -1022,7 +1023,7 @@ export function SelectedLayerPropertiesContent({
uiSound="action.randomize"
variant="secondary"
>
- Randomize
+ Shuffle
diff --git a/src/lib/editor/config/layer-catalog.ts b/src/lib/editor/config/layer-catalog.ts
index c02d5f94..95d6df9b 100644
--- a/src/lib/editor/config/layer-catalog.ts
+++ b/src/lib/editor/config/layer-catalog.ts
@@ -388,6 +388,7 @@ export const NEW_LAYER_TYPES: ReadonlySet = new Set([
export const REWORKED_LAYER_TYPES: ReadonlySet = new Set([
"blob-tracking",
+ "gradient",
"pattern",
"plotter",
"threshold",
diff --git a/src/lib/editor/config/layer-registry.ts b/src/lib/editor/config/layer-registry.ts
index c2299dee..264b9158 100644
--- a/src/lib/editor/config/layer-registry.ts
+++ b/src/lib/editor/config/layer-registry.ts
@@ -1,3 +1,4 @@
+import { MESH_GRADIENT_PRESETS } from "@/lib/editor/mesh-gradient"
import {
EFFECT_PICKER_ORDER,
getLayerLabel,
@@ -1014,21 +1015,21 @@ const bloomParams = [
const gradientParams = [
{
- defaultValue: "custom",
+ defaultValue: "peach",
group: "Points",
key: "preset",
label: "Preset",
options: [
{ label: "Custom", value: "custom" },
- { label: "Forest", value: "aurora" },
- { label: "Ember", value: "sunset" },
- { label: "Abyss", value: "deep-ocean" },
- { label: "Violet", value: "neon-glow" },
+ ...MESH_GRADIENT_PRESETS.map((preset) => ({
+ label: preset.label,
+ value: preset.id,
+ })),
],
type: "select",
},
{
- defaultValue: 3,
+ defaultValue: 5,
group: "Points",
key: "activePoints",
label: "Active Points",
@@ -1038,14 +1039,14 @@ const gradientParams = [
type: "number",
},
{
- defaultValue: "#0E0C0C",
+ defaultValue: "#ff7a59",
group: "Points",
key: "point1Color",
label: "Point 1 Color",
type: "color",
},
{
- defaultValue: [-0.82, -0.62] as [number, number],
+ defaultValue: [-1.15, 0.55] as [number, number],
group: "Points",
key: "point1Position",
label: "Point 1 Position",
@@ -1055,7 +1056,7 @@ const gradientParams = [
type: "vec2",
},
{
- defaultValue: 0.42,
+ defaultValue: 1.2,
group: "Points",
key: "point1Weight",
label: "Point 1 Weight",
@@ -1065,14 +1066,14 @@ const gradientParams = [
type: "number",
},
{
- defaultValue: "#C1FF00",
+ defaultValue: "#ffd9b8",
group: "Points",
key: "point2Color",
label: "Point 2 Color",
type: "color",
},
{
- defaultValue: [0.22, 0.72] as [number, number],
+ defaultValue: [1.05, 0.7] as [number, number],
group: "Points",
key: "point2Position",
label: "Point 2 Position",
@@ -1082,7 +1083,7 @@ const gradientParams = [
type: "vec2",
},
{
- defaultValue: 1.55,
+ defaultValue: 1,
group: "Points",
key: "point2Weight",
label: "Point 2 Weight",
@@ -1092,7 +1093,7 @@ const gradientParams = [
type: "number",
},
{
- defaultValue: "#B2DAD5",
+ defaultValue: "#ffb3c7",
group: "Points",
key: "point3Color",
label: "Point 3 Color",
@@ -1103,7 +1104,7 @@ const gradientParams = [
},
},
{
- defaultValue: [0.88, -0.26] as [number, number],
+ defaultValue: [0.05, -0.15] as [number, number],
group: "Points",
key: "point3Position",
label: "Point 3 Position",
@@ -1117,7 +1118,7 @@ const gradientParams = [
},
},
{
- defaultValue: 0.64,
+ defaultValue: 0.9,
group: "Points",
key: "point3Weight",
label: "Point 3 Weight",
@@ -1131,7 +1132,7 @@ const gradientParams = [
},
},
{
- defaultValue: "#3B4148",
+ defaultValue: "#ffc857",
group: "Points",
key: "point4Color",
label: "Point 4 Color",
@@ -1142,7 +1143,7 @@ const gradientParams = [
},
},
{
- defaultValue: [-0.34, 0.52] as [number, number],
+ defaultValue: [-0.7, -0.8] as [number, number],
group: "Points",
key: "point4Position",
label: "Point 4 Position",
@@ -1156,7 +1157,7 @@ const gradientParams = [
},
},
{
- defaultValue: 0.82,
+ defaultValue: 1,
group: "Points",
key: "point4Weight",
label: "Point 4 Weight",
@@ -1170,7 +1171,7 @@ const gradientParams = [
},
},
{
- defaultValue: "#F3E7D0",
+ defaultValue: "#fff1e3",
group: "Points",
key: "point5Color",
label: "Point 5 Color",
@@ -1181,7 +1182,7 @@ const gradientParams = [
},
},
{
- defaultValue: [0.58, -0.76] as [number, number],
+ defaultValue: [1.2, -0.7] as [number, number],
group: "Points",
key: "point5Position",
label: "Point 5 Position",
@@ -1195,7 +1196,7 @@ const gradientParams = [
},
},
{
- defaultValue: 0.48,
+ defaultValue: 1.1,
group: "Points",
key: "point5Weight",
label: "Point 5 Weight",
@@ -1208,6 +1209,54 @@ const gradientParams = [
key: "activePoints",
},
},
+ {
+ animatable: false,
+ defaultValue: "simple",
+ group: "Distortion",
+ key: "distortionMode",
+ label: "Mode",
+ options: [
+ { label: "Simple", value: "simple" },
+ { label: "Advanced", value: "advanced" },
+ ],
+ type: "select",
+ },
+ {
+ defaultValue: 0.7,
+ description: "How far the colors melt into each other.",
+ group: "Distortion",
+ key: "softness",
+ label: "Softness",
+ max: 1,
+ min: 0,
+ step: 0.01,
+ type: "number",
+ visibleWhen: { equals: "simple", key: "distortionMode" },
+ },
+ {
+ defaultValue: 0.3,
+ description: "How much the color fields bend and ripple.",
+ group: "Distortion",
+ key: "warp",
+ label: "Warp",
+ max: 1,
+ min: 0,
+ step: 0.01,
+ type: "number",
+ visibleWhen: { equals: "simple", key: "distortionMode" },
+ },
+ {
+ defaultValue: 0.1,
+ description: "Adds finer ripples on top of the large shapes.",
+ group: "Distortion",
+ key: "detail",
+ label: "Detail",
+ max: 1,
+ min: 0,
+ step: 0.01,
+ type: "number",
+ visibleWhen: { equals: "simple", key: "distortionMode" },
+ },
{
defaultValue: "ridge",
group: "Distortion",
@@ -1222,6 +1271,7 @@ const gradientParams = [
{ label: "Turbulence", value: "turbulence" },
],
type: "select",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 70.3,
@@ -1232,6 +1282,7 @@ const gradientParams = [
min: 0,
step: 0.1,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 0.18,
@@ -1242,6 +1293,7 @@ const gradientParams = [
min: 0,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 2.35,
@@ -1252,6 +1304,7 @@ const gradientParams = [
min: 0.1,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 1,
@@ -1262,6 +1315,7 @@ const gradientParams = [
min: 1,
step: 1,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 1,
@@ -1272,6 +1326,7 @@ const gradientParams = [
min: 0.1,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 0.5,
@@ -1282,6 +1337,7 @@ const gradientParams = [
min: 0,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: 0.69,
@@ -1292,6 +1348,7 @@ const gradientParams = [
min: -1,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
defaultValue: true,
@@ -1299,30 +1356,46 @@ const gradientParams = [
key: "animate",
label: "Animate",
type: "boolean",
+ visibleWhen: { equals: "gradient", key: "__internal" },
},
{
- defaultValue: 1,
+ animatable: false,
+ defaultValue: "breathe",
+ description: "Every style loops seamlessly over the timeline duration.",
+ group: "Animation",
+ key: "motionStyle",
+ label: "Motion",
+ options: [
+ { label: "Off", value: "off" },
+ { label: "Drift", value: "drift" },
+ { label: "Breathe", value: "breathe" },
+ { label: "Swirl", value: "swirl" },
+ { label: "Flow", value: "flow" },
+ { label: "Original", value: "original" },
+ ],
+ type: "select",
+ },
+ {
+ defaultValue: 0.4,
group: "Animation",
key: "motionAmount",
- label: "Motion Amount",
+ label: "Amount",
max: 1,
min: 0,
step: 0.01,
type: "number",
+ visibleWhen: { key: "motionStyle", notEquals: "off" },
},
{
- defaultValue: 0.4,
+ defaultValue: 0.3,
group: "Animation",
key: "motionSpeed",
- label: "Motion Speed",
+ label: "Speed",
max: 2,
min: 0,
step: 0.01,
type: "number",
- visibleWhen: {
- equals: true,
- key: "animate",
- },
+ visibleWhen: { key: "motionStyle", notEquals: "off" },
},
{
defaultValue: 2.05,
@@ -1333,9 +1406,10 @@ const gradientParams = [
min: 0.5,
step: 0.01,
type: "number",
+ visibleWhen: { equals: "advanced", key: "distortionMode" },
},
{
- defaultValue: "cinematic",
+ defaultValue: "none",
group: "Finish",
key: "tonemapMode",
label: "Tonemap",
@@ -1369,7 +1443,7 @@ const gradientParams = [
type: "number",
},
{
- defaultValue: 0.08,
+ defaultValue: 0.04,
group: "Finish",
key: "grainAmount",
label: "Grain",
@@ -1379,7 +1453,7 @@ const gradientParams = [
type: "number",
},
{
- defaultValue: 0.12,
+ defaultValue: 0,
group: "Finish",
key: "vignetteStrength",
label: "Vignette Strength",
diff --git a/src/lib/editor/mesh-gradient.ts b/src/lib/editor/mesh-gradient.ts
new file mode 100644
index 00000000..6611caf4
--- /dev/null
+++ b/src/lib/editor/mesh-gradient.ts
@@ -0,0 +1,289 @@
+import type { LayerParameterValues, ParameterValue } from "@/types/editor"
+
+type Point = [number, number]
+type PresetPoint = { color: string; position: Point; weight: number }
+type MeshGradientPreset = {
+ id: string
+ label: string
+ points: PresetPoint[]
+ softness: number
+ warp: number
+ detail: number
+ motionStyle: string
+ motionAmount: number
+ motionSpeed: number
+ grainAmount: number
+}
+
+export const MESH_GRADIENT_PRESETS: readonly MeshGradientPreset[] = [
+ {
+ id: "peach",
+ label: "Peach",
+ points: [
+ { color: "#ff7a59", position: [-1.15, 0.55], weight: 1.2 },
+ { color: "#ffd9b8", position: [1.05, 0.7], weight: 1 },
+ { color: "#ffb3c7", position: [0.05, -0.15], weight: 0.9 },
+ { color: "#ffc857", position: [-0.7, -0.8], weight: 1 },
+ { color: "#fff1e3", position: [1.2, -0.7], weight: 1.1 },
+ ],
+ softness: 0.7,
+ warp: 0.3,
+ detail: 0.1,
+ motionStyle: "breathe",
+ motionAmount: 0.4,
+ motionSpeed: 0.3,
+ grainAmount: 0.04,
+ },
+ {
+ id: "lagoon",
+ label: "Lagoon",
+ points: [
+ { color: "#06303c", position: [-1.2, 0.7], weight: 1.3 },
+ { color: "#138a8a", position: [0.1, 0.4], weight: 1 },
+ { color: "#7fe0cf", position: [1.1, 0.25], weight: 1 },
+ { color: "#2d6fd6", position: [-0.6, -0.65], weight: 0.9 },
+ { color: "#e4fbf4", position: [1.05, -0.8], weight: 0.9 },
+ ],
+ softness: 0.6,
+ warp: 0.4,
+ detail: 0.15,
+ motionStyle: "flow",
+ motionAmount: 0.45,
+ motionSpeed: 0.3,
+ grainAmount: 0.05,
+ },
+ {
+ id: "dusk",
+ label: "Dusk",
+ points: [
+ { color: "#160f3a", position: [-1.2, 0.75], weight: 1.4 },
+ { color: "#5a2a8a", position: [-0.2, 0.35], weight: 1 },
+ { color: "#e2557f", position: [0.75, 0.05], weight: 1 },
+ { color: "#ff9e6d", position: [-0.45, -0.75], weight: 1 },
+ { color: "#ffe08a", position: [1.2, -0.75], weight: 0.9 },
+ ],
+ softness: 0.65,
+ warp: 0.35,
+ detail: 0.1,
+ motionStyle: "swirl",
+ motionAmount: 0.35,
+ motionSpeed: 0.25,
+ grainAmount: 0.05,
+ },
+ {
+ id: "citrus",
+ label: "Citrus",
+ points: [
+ { color: "#ff6a00", position: [-1.1, -0.6], weight: 1.2 },
+ { color: "#ffc93c", position: [-0.2, 0.55], weight: 1.1 },
+ { color: "#a7d930", position: [0.95, 0.6], weight: 0.9 },
+ { color: "#fff6c2", position: [0.6, -0.55], weight: 1 },
+ { color: "#ff9f1c", position: [1.25, -0.1], weight: 0.8 },
+ ],
+ softness: 0.7,
+ warp: 0.25,
+ detail: 0.1,
+ motionStyle: "drift",
+ motionAmount: 0.35,
+ motionSpeed: 0.3,
+ grainAmount: 0.04,
+ },
+ {
+ id: "mint",
+ label: "Mint",
+ points: [
+ { color: "#a8e6cf", position: [-1.1, 0.6], weight: 1.1 },
+ { color: "#f3fbf7", position: [0.15, 0.15], weight: 1.2 },
+ { color: "#56c596", position: [-0.5, -0.75], weight: 0.9 },
+ { color: "#c9ecff", position: [1.1, 0.55], weight: 1 },
+ { color: "#e6f5c9", position: [1.15, -0.7], weight: 0.9 },
+ ],
+ softness: 0.75,
+ warp: 0.2,
+ detail: 0.05,
+ motionStyle: "drift",
+ motionAmount: 0.3,
+ motionSpeed: 0.25,
+ grainAmount: 0.03,
+ },
+ {
+ id: "polar",
+ label: "Aurora",
+ points: [
+ { color: "#030d14", position: [-0.2, -0.75], weight: 1.5 },
+ { color: "#00c2a2", position: [-1.0, 0.45], weight: 1 },
+ { color: "#7b2ff7", position: [0.65, 0.6], weight: 1 },
+ { color: "#1e8fff", position: [1.25, -0.25], weight: 0.9 },
+ { color: "#b6fff6", position: [-0.3, 0.85], weight: 0.6 },
+ ],
+ softness: 0.55,
+ warp: 0.45,
+ detail: 0.2,
+ motionStyle: "flow",
+ motionAmount: 0.5,
+ motionSpeed: 0.3,
+ grainAmount: 0.05,
+ },
+ {
+ id: "ink",
+ label: "Ink",
+ points: [
+ { color: "#050608", position: [-1.0, 0.5], weight: 1.4 },
+ { color: "#141b2d", position: [0.9, 0.7], weight: 1.2 },
+ { color: "#3a1c71", position: [0.2, -0.2], weight: 0.9 },
+ { color: "#ff4d00", position: [1.1, -0.65], weight: 0.7 },
+ { color: "#0b0b10", position: [-1.1, -0.75], weight: 1.2 },
+ ],
+ softness: 0.6,
+ warp: 0.3,
+ detail: 0.15,
+ motionStyle: "drift",
+ motionAmount: 0.3,
+ motionSpeed: 0.25,
+ grainAmount: 0.06,
+ },
+ {
+ id: "basement",
+ label: "Basement",
+ points: [
+ { color: "#0a0a0a", position: [-1.15, 0.6], weight: 1.3 },
+ { color: "#ff4d00", position: [0.15, 0.1], weight: 1.1 },
+ { color: "#ff8a3d", position: [1.1, 0.65], weight: 0.9 },
+ { color: "#ffd8bf", position: [1.1, -0.7], weight: 0.9 },
+ { color: "#2a1206", position: [-0.75, -0.8], weight: 1.1 },
+ ],
+ softness: 0.6,
+ warp: 0.35,
+ detail: 0.1,
+ motionStyle: "breathe",
+ motionAmount: 0.35,
+ motionSpeed: 0.3,
+ grainAmount: 0.06,
+ },
+]
+
+export const LEGACY_MESH_GRADIENT_PRESETS = new Set([
+ "aurora",
+ "sunset",
+ "deep-ocean",
+ "neon-glow",
+])
+
+export function meshGradientPresetParams(
+ id: string
+): Record | null {
+ const preset = MESH_GRADIENT_PRESETS.find((entry) => entry.id === id)
+ if (!preset) return null
+ const params: Record = {
+ activePoints: preset.points.length,
+ distortionMode: "simple",
+ softness: preset.softness,
+ warp: preset.warp,
+ detail: preset.detail,
+ motionStyle: preset.motionStyle,
+ motionAmount: preset.motionAmount,
+ motionSpeed: preset.motionSpeed,
+ tonemapMode: "none",
+ glowStrength: 0,
+ grainAmount: preset.grainAmount,
+ vignetteStrength: 0,
+ }
+ for (const [index, point] of preset.points.entries()) {
+ params[`point${index + 1}Color`] = point.color
+ params[`point${index + 1}Position`] = point.position
+ params[`point${index + 1}Weight`] = point.weight
+ }
+ return params
+}
+
+function oklchToHex(l: number, c: number, h: number): string {
+ for (let chroma = c; chroma >= 0; chroma -= 0.01) {
+ const a = chroma * Math.cos((h * Math.PI) / 180)
+ const b = chroma * Math.sin((h * Math.PI) / 180)
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
+ const linear = [
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
+ ]
+ if (linear.every((value) => value >= -0.0005 && value <= 1.0005)) {
+ return `#${linear
+ .map((value) => {
+ const v = Math.min(1, Math.max(0, value))
+ const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055
+ return Math.round(srgb * 255)
+ .toString(16)
+ .padStart(2, "0")
+ })
+ .join("")}`
+ }
+ }
+ return "#808080"
+}
+
+const HARMONIES: readonly (readonly number[])[] = [
+ [0, 18, 36, -18, -36],
+ [0, 150, 210, 20, -20],
+ [0, 120, 240, 30, 150],
+ [0, 8, -8, 16, -16],
+ [0, 180, 20, 200, -20],
+]
+
+function shuffled(items: readonly T[], random: () => number): T[] {
+ const result = [...items]
+ for (let index = result.length - 1; index > 0; index -= 1) {
+ const swap = Math.floor(random() * (index + 1))
+ ;[result[index], result[swap]] = [result[swap]!, result[index]!]
+ }
+ return result
+}
+
+function spreadPoints(count: number, random: () => number): Point[] {
+ const points: Point[] = []
+ for (let attempt = 0; points.length < count && attempt < 400; attempt += 1) {
+ const candidate: Point = [
+ Number((random() * 2.6 - 1.3).toFixed(2)),
+ Number((random() * 1.7 - 0.85).toFixed(2)),
+ ]
+ const clear = points.every(
+ ([x, y]) => Math.hypot(x - candidate[0], y - candidate[1]) > 0.75
+ )
+ if (clear || attempt > 300) points.push(candidate)
+ }
+ return points
+}
+
+export function shuffleMeshGradient(
+ random: () => number = Math.random
+): LayerParameterValues {
+ const count = random() < 0.35 ? 4 : 5
+ const colors: string[] = []
+ if (random() < 0.35) {
+ const preset = MESH_GRADIENT_PRESETS[Math.floor(random() * MESH_GRADIENT_PRESETS.length)]!
+ colors.push(...preset.points.map((point) => point.color))
+ } else {
+ const hue = random() * 360
+ const harmony = HARMONIES[Math.floor(random() * HARMONIES.length)]!
+ const dark = random() < 0.5
+ const lightness = dark
+ ? [0.22, 0.38, 0.55, 0.68, 0.82]
+ : [0.42, 0.62, 0.74, 0.86, 0.95]
+ for (let index = 0; index < 5; index += 1) {
+ const l = lightness[index]! + (random() - 0.5) * 0.05
+ const edge = index === 0 || index === 4
+ const c = (edge ? 0.05 : 0.12) + random() * 0.06
+ colors.push(oklchToHex(l, c, (hue + harmony[index]! + 360) % 360))
+ }
+ }
+ const order = shuffled(colors, random)
+ const positions = spreadPoints(5, random)
+ const params: LayerParameterValues = { preset: "custom", activePoints: count }
+ for (let index = 0; index < 5; index += 1) {
+ params[`point${index + 1}Color`] = order[index] ?? "#ffffff"
+ params[`point${index + 1}Position`] = positions[index] ?? [0, 0]
+ params[`point${index + 1}Weight`] = Number((0.8 + random() * 0.5).toFixed(2))
+ }
+ return params
+}
diff --git a/src/lib/editor/project-file.ts b/src/lib/editor/project-file.ts
index 5b8406dc..ed40465a 100644
--- a/src/lib/editor/project-file.ts
+++ b/src/lib/editor/project-file.ts
@@ -3,6 +3,7 @@ import {
normalizeCompositionForDocument,
} from "@/lib/editor/composition"
import { modelMaterialDefaults } from "@/lib/editor/config/model-options"
+import { LEGACY_MESH_GRADIENT_PRESETS } from "@/lib/editor/mesh-gradient"
import { CURRENT_PROJECT_FILE_VERSION } from "./project-version"
import { validateLayerHierarchy } from "@/renderer/layer-hierarchy"
import { MISSING_DEPTH_ERROR_PREFIX } from "@/renderer/layer-media-error"
@@ -726,6 +727,17 @@ export function migrateLayerParams(
}
}
+ if (layer.type === "gradient") {
+ if (params.distortionMode === undefined) params.distortionMode = "advanced"
+ if (params.motionStyle === undefined)
+ params.motionStyle = params.animate === false ? "off" : "original"
+ if (
+ typeof params.preset === "string" &&
+ LEGACY_MESH_GRADIENT_PRESETS.has(params.preset)
+ )
+ params.preset = "custom"
+ }
+
if (layer.type === "text" && params.backgroundAlpha === undefined) {
params.backgroundAlpha = 1
}
diff --git a/src/renderer/create-webgpu-renderer.ts b/src/renderer/create-webgpu-renderer.ts
index e5687971..868e8770 100644
--- a/src/renderer/create-webgpu-renderer.ts
+++ b/src/renderer/create-webgpu-renderer.ts
@@ -75,6 +75,7 @@ export async function createWebGPURenderer(
}
pipeline.updateLogicalSize(frame.logicalSize)
+ pipeline.updateTimelineDuration(frame.clock.duration)
pipeline.updateBackgroundColor(frame.sceneConfig.backgroundColor)
pipeline.updateSceneConfig(frame.sceneConfig)
pipeline.updateOutputCropAspectRatio(frame.cropAspectRatio)
diff --git a/src/renderer/gradient-pass.ts b/src/renderer/gradient-pass.ts
index 6ed9c9a9..49df54f4 100644
--- a/src/renderer/gradient-pass.ts
+++ b/src/renderer/gradient-pass.ts
@@ -43,6 +43,29 @@ type NoiseMode =
| "value"
| "voronoi"
type TonemapMode = "aces" | "cinematic" | "none" | "reinhard" | "totos"
+type MotionStyle = "breathe" | "drift" | "flow" | "off" | "original" | "swirl"
+
+const MOTION_STYLES: readonly MotionStyle[] = [
+ "breathe",
+ "drift",
+ "flow",
+ "off",
+ "original",
+ "swirl",
+]
+const FALLBACK_LOOP_SECONDS = 10
+
+function resolveMotionStyle(params: LayerParameterValues): MotionStyle {
+ if (MOTION_STYLES.includes(params.motionStyle as MotionStyle))
+ return params.motionStyle as MotionStyle
+ return params.animate === false ? "off" : "original"
+}
+
+function unit(value: unknown, fallback: number): number {
+ return typeof value === "number" && Number.isFinite(value)
+ ? Math.min(1, Math.max(0, value))
+ : fallback
+}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.trim().replace("#", "")
@@ -80,6 +103,8 @@ export class GradientPass extends PassNode {
}[]
private readonly pointWeightUniforms: Node[]
private readonly timeUniform: Node
+ private readonly loopPhaseUniform: Node
+ private readonly coreUniform: Node
private readonly vignetteRadiusUniform: Node
private readonly vignetteSoftnessUniform: Node
private readonly vignetteStrengthUniform: Node
@@ -91,6 +116,9 @@ export class GradientPass extends PassNode {
private readonly warpScaleUniform: Node
private isAnimated = true
+ private motionStyle: MotionStyle = "original"
+ private motionSpeed = 0.2
+ private loopSeconds = FALLBACK_LOOP_SECONDS
private noiseMode: NoiseMode = "simplex"
private tonemapMode: TonemapMode = "aces"
private warpIterations = 1
@@ -98,6 +126,8 @@ export class GradientPass extends PassNode {
constructor(layerId: string) {
super(layerId)
this.timeUniform = uniform(0)
+ this.loopPhaseUniform = uniform(1)
+ this.coreUniform = uniform(0)
this.animateUniform = uniform(1)
this.aspectUniform = uniform(1)
this.activePointsUniform = uniform(5)
@@ -133,28 +163,42 @@ export class GradientPass extends PassNode {
typeof params.activePoints === "number"
? Math.max(2, Math.min(5, Math.round(params.activePoints)))
: 5
+ const simple = params.distortionMode === "simple"
+ const softness = unit(params.softness, 0.6)
+ const warp = unit(params.warp, 0.3)
+ const detail = unit(params.detail, 0.2)
const noiseSeed =
typeof params.noiseSeed === "number" ? params.noiseSeed : 0
- const warpAmount =
+ const warpAmountRaw =
typeof params.warpAmount === "number"
? Math.max(0, params.warpAmount)
: 0.18
- const warpScale =
+ const warpAmount = simple ? warp * 0.75 : warpAmountRaw
+ const warpScaleRaw =
typeof params.warpScale === "number"
? Math.max(0.05, params.warpScale)
: 1.4
- const warpIterations =
+ const warpScale = simple ? 0.55 + warp * 0.6 + detail * 1.6 : warpScaleRaw
+ const warpIterationsRaw =
typeof params.warpIterations === "number"
? Math.max(1, Math.min(5, Math.round(params.warpIterations)))
: 1
- const warpDecay =
- typeof params.warpDecay === "number" ? Math.max(0.1, params.warpDecay) : 1
- const warpBias =
+ const warpIterations = simple ? 1 + Math.round(detail * 2) : warpIterationsRaw
+ const warpDecayRaw =
+ typeof params.warpDecay === "number"
+ ? Math.max(0.1, params.warpDecay)
+ : 1
+ const warpDecay = simple ? 1.6 - detail * 0.8 : warpDecayRaw
+ const warpBiasRaw =
typeof params.warpBias === "number"
? Math.max(0, Math.min(1, params.warpBias))
: 0.5
- const vortexAmount =
- typeof params.vortexAmount === "number" ? params.vortexAmount : 0.12
+ const warpBias = simple ? 0.5 : warpBiasRaw
+ const vortexAmountRaw =
+ typeof params.vortexAmount === "number"
+ ? params.vortexAmount
+ : 0.12
+ const vortexAmount = simple ? 0 : vortexAmountRaw
const motionAmount =
typeof params.motionAmount === "number"
? Math.max(0, params.motionAmount)
@@ -163,9 +207,13 @@ export class GradientPass extends PassNode {
typeof params.motionSpeed === "number"
? Math.max(0, params.motionSpeed)
: 0.2
- const animateEnabled = params.animate !== false
- const falloff =
- typeof params.falloff === "number" ? Math.max(0.25, params.falloff) : 1.85
+ const motionStyle = resolveMotionStyle(params)
+ const animateEnabled = motionStyle !== "off"
+ const falloffRaw =
+ typeof params.falloff === "number"
+ ? Math.max(0.25, params.falloff)
+ : 1.85
+ const falloff = simple ? 4.6 - softness * 3.4 : falloffRaw
const glowStrength =
typeof params.glowStrength === "number"
? Math.max(0, params.glowStrength)
@@ -201,6 +249,7 @@ export class GradientPass extends PassNode {
this.motionAmountUniform.value = motionAmount
this.motionSpeedUniform.value = motionSpeed
this.falloffUniform.value = falloff
+ this.coreUniform.value = simple ? 0.08 + softness * 0.22 : 0
this.glowStrengthUniform.value = glowStrength
this.glowThresholdUniform.value = glowThreshold
this.grainAmountUniform.value = grainAmount
@@ -234,13 +283,14 @@ export class GradientPass extends PassNode {
let nextTonemapMode: TonemapMode = "aces"
let nextNoiseMode: NoiseMode = "simplex"
- switch (params.noiseType) {
+ const noiseType = simple ? "simplex" : params.noiseType
+ switch (noiseType) {
case "perlin":
case "ridge":
case "turbulence":
case "value":
case "voronoi":
- nextNoiseMode = params.noiseType
+ nextNoiseMode = noiseType
break
default:
nextNoiseMode = "simplex"
@@ -264,6 +314,8 @@ export class GradientPass extends PassNode {
this.isAnimated =
animateEnabled && motionSpeed > 0 && (motionAmount > 0 || warpAmount > 0)
+ this.motionSpeed = motionSpeed
+ this.updateLoopPhase()
let needsRebuild = false
@@ -282,6 +334,11 @@ export class GradientPass extends PassNode {
needsRebuild = true
}
+ if (motionStyle !== this.motionStyle) {
+ this.motionStyle = motionStyle
+ needsRebuild = true
+ }
+
if (noiseSeed !== this.noiseSeed) {
this.noiseSeed = noiseSeed
needsRebuild = true
@@ -300,6 +357,19 @@ export class GradientPass extends PassNode {
return this.isAnimated
}
+ override updateTimelineDuration(duration: number): boolean {
+ const next = duration > 0 ? duration : FALLBACK_LOOP_SECONDS
+ if (next === this.loopSeconds) return false
+ this.loopSeconds = next
+ this.updateLoopPhase()
+ return this.isAnimated
+ }
+
+ private updateLoopPhase(): void {
+ const cycles = Math.max(1, Math.round((this.motionSpeed * this.loopSeconds) / 2))
+ this.loopPhaseUniform.value = (Math.PI * 2 * cycles) / this.loopSeconds
+ }
+
protected override beforeRender(time: number): void {
this.timeUniform.value = time
}
@@ -342,10 +412,17 @@ export class GradientPass extends PassNode {
uv().x.mul(2).sub(1),
float(1).sub(uv().y).mul(2).sub(1)
)
+ const original = this.motionStyle === "original"
const time = this.timeUniform
.mul(this.motionSpeedUniform)
.mul(this.animateUniform)
- const warpedUv = baseUv.toVar()
+ const phase = this.timeUniform
+ .mul(this.loopPhaseUniform)
+ .mul(this.animateUniform)
+ const amount = this.motionAmountUniform
+ const breathe = this.motionStyle === "breathe"
+ const zoom = breathe ? float(1).add(sin(phase).mul(amount).mul(0.06)) : float(1)
+ const warpedUv = baseUv.div(zoom).toVar()
const biasX = this.warpBiasUniform.mul(2)
const biasY = float(1).sub(this.warpBiasUniform).mul(2)
@@ -353,11 +430,20 @@ export class GradientPass extends PassNode {
const strength = this.warpAmountUniform.div(
pow(float(i), this.warpDecayUniform)
)
+ const flowOffset =
+ this.motionStyle === "flow"
+ ? vec2(cos(phase), sin(phase)).mul(amount).mul(0.6)
+ : vec2(0, 0)
const warpInput = warpedUv
.mul(this.warpScaleUniform)
.add(float(this.noiseSeed).mul(73.7))
- const timeOffsetX = time.mul(0.1).add(float(i * 100))
- const timeOffsetY = time.mul(0.1).add(float(i * 200))
+ .add(flowOffset)
+ const timeOffsetX = original
+ ? time.mul(0.1).add(float(i * 100))
+ : float(i * 100)
+ const timeOffsetY = original
+ ? time.mul(0.1).add(float(i * 200))
+ : float(i * 200)
let noiseX: Node
let noiseY: Node
@@ -418,7 +504,11 @@ export class GradientPass extends PassNode {
dot(warpedUv, warpedUv),
float(1e-4)
).sqrt()
- const vortexAngle = distanceFromCenter.mul(this.vortexAmountUniform)
+ const vortex =
+ this.motionStyle === "swirl"
+ ? this.vortexAmountUniform.add(sin(phase).mul(amount).mul(1.2))
+ : this.vortexAmountUniform
+ const vortexAngle = distanceFromCenter.mul(vortex)
const rotatedUv = vec2(
warpedUv.x.mul(cos(vortexAngle)).sub(warpedUv.y.mul(sin(vortexAngle))),
warpedUv.x.mul(sin(vortexAngle)).add(warpedUv.y.mul(cos(vortexAngle)))
@@ -434,25 +524,37 @@ export class GradientPass extends PassNode {
float(1),
float(0)
)
+ const drift = this.motionStyle === "drift"
+ const harmonic = float(index % 2 === 0 ? 1 : 2)
+ const legacyOffset = vec2(
+ sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)),
+ cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7)))
+ ).mul(amount)
+ const driftOffset = vec2(
+ sin(phase.add(pointIndex.mul(1.3))),
+ cos(phase.mul(harmonic).add(pointIndex.mul(2.1))).mul(0.8)
+ ).mul(amount)
+ let offset: Node = vec2(0, 0)
+ if (original) offset = legacyOffset
+ else if (drift) offset = driftOffset
const pointPosition = vec2(
- this.pointPositionUniforms[index]!.x.add(
- sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)).mul(
- this.motionAmountUniform
- )
- ),
- this.pointPositionUniforms[index]!.y.add(
- cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7))).mul(
- this.motionAmountUniform
- )
- )
- )
+ this.pointPositionUniforms[index]!.x,
+ this.pointPositionUniforms[index]!.y
+ ).add(offset)
const delta = rotatedUv.sub(pointPosition)
- const distance = max(dot(delta, delta), float(1e-4)).sqrt()
+ const distance = max(
+ dot(delta, delta).add(this.coreUniform.mul(this.coreUniform)),
+ float(1e-4)
+ ).sqrt()
const baseWeight = float(1).div(
max(pow(distance, this.falloffUniform), float(1e-4))
)
+ const pulse = breathe
+ ? float(1).add(sin(phase.add(pointIndex.mul(1.25))).mul(amount).mul(0.6))
+ : float(1)
const weighted = baseWeight
.mul(this.pointWeightUniforms[index]!)
+ .mul(pulse)
.mul(active)
const color = vec3(
this.pointColorUniforms[index]!.red,
diff --git a/src/renderer/layer-mask.ts b/src/renderer/layer-mask.ts
index 317644ab..e33d4328 100644
--- a/src/renderer/layer-mask.ts
+++ b/src/renderer/layer-mask.ts
@@ -201,9 +201,17 @@ export function brushSignedDistance(
const toOutside = squaredDistanceTo(paint, false, size, spacingX, spacingY)
const field = new Uint16Array(size * size)
for (let index = 0; index < field.length; index += 1) {
+ const x = index % size
+ const y = Math.floor(index / size)
+ const toBounds = Math.min(
+ (x + 0.5) * spacingX,
+ (size - 0.5 - x) * spacingX,
+ (y + 0.5) * spacingY,
+ (size - 0.5 - y) * spacingY
+ )
const distance =
paint[index] !== 0
- ? -Math.sqrt(toOutside[index]!)
+ ? -Math.min(Math.sqrt(toOutside[index]!), toBounds)
: Math.sqrt(toInside[index]!)
field[index] = THREE.DataUtils.toHalfFloat(
Math.max(-MAX_BRUSH_DISTANCE, Math.min(MAX_BRUSH_DISTANCE, distance))
@@ -256,7 +264,9 @@ export class LayerMaskNode {
if (next.shape === "brush") {
if (!this.paintTexture) {
this.paintTexture = new THREE.DataTexture(
- new Uint16Array(CELL_PAINT_SIZE ** 2),
+ new Uint16Array(CELL_PAINT_SIZE ** 2).fill(
+ THREE.DataUtils.toHalfFloat(MAX_BRUSH_DISTANCE)
+ ),
CELL_PAINT_SIZE,
CELL_PAINT_SIZE,
THREE.RedFormat,
diff --git a/src/renderer/pass-node.ts b/src/renderer/pass-node.ts
index 40ebc42b..a5e908d7 100644
--- a/src/renderer/pass-node.ts
+++ b/src/renderer/pass-node.ts
@@ -231,6 +231,10 @@ export class PassNode {
return false
}
+ updateTimelineDuration(_duration: number): boolean {
+ return false
+ }
+
updateOutputCropAspectRatio(_ratio: number | null): boolean {
return false
}
diff --git a/src/renderer/pattern-pass.ts b/src/renderer/pattern-pass.ts
index 064e9301..a08ed68e 100644
--- a/src/renderer/pattern-pass.ts
+++ b/src/renderer/pattern-pass.ts
@@ -601,6 +601,12 @@ export class PatternPass extends PassNode {
motifs: number
texture: THREE.CanvasTexture | null
}> {
+ for (const key of this.motifImages.keys()) {
+ const separator = key.indexOf("\n")
+ if (separator >= 0 && key.slice(separator + 1) !== this.motifPalette) {
+ this.motifImages.delete(key)
+ }
+ }
const results = await Promise.allSettled(
this.motifUrls.map((url) => {
const palette = this.motifPalette
diff --git a/src/renderer/pipeline-manager.ts b/src/renderer/pipeline-manager.ts
index ddd447a6..eb4fa17c 100644
--- a/src/renderer/pipeline-manager.ts
+++ b/src/renderer/pipeline-manager.ts
@@ -203,6 +203,7 @@ export class PipelineManager {
private width: number
private height: number
private logicalWidth: number
+ private timelineDuration = 0
private logicalHeight: number
private sceneConfig: SceneConfig | null = null
private readonly baseMaterial: THREE.MeshBasicMaterial
@@ -325,6 +326,7 @@ export class PipelineManager {
: this.createPass(node.layer)
pass.resize(this.width, this.height)
pass.updateLogicalSize(this.logicalWidth, this.logicalHeight)
+ pass.updateTimelineDuration(this.timelineDuration)
this.syncMaskFrame(pass)
this.passMap.set(layerId, pass)
this.markDirty()
@@ -507,6 +509,17 @@ export class PipelineManager {
this.markDirty()
}
+ updateTimelineDuration(duration: number): void {
+ const next = Number.isFinite(duration) ? Math.max(0, duration) : 0
+ if (next === this.timelineDuration) return
+ this.timelineDuration = next
+ let changed = false
+ for (const pass of this.passMap.values()) {
+ changed = pass.updateTimelineDuration(next) || changed
+ }
+ if (changed) this.markDirty()
+ }
+
updateLogicalSize(size: Size): void {
const nextWidth = Math.max(1, size.width)
const nextHeight = Math.max(1, size.height)
diff --git a/src/store/canvas-handles-store.ts b/src/store/canvas-handles-store.ts
index 578b0e81..72e83ecb 100644
--- a/src/store/canvas-handles-store.ts
+++ b/src/store/canvas-handles-store.ts
@@ -3,6 +3,12 @@ import { useLayerStore } from "@/store/layer-store"
const HANDLE_PARAMS: Partial>> = {
annotations: new Set(["targetCenter", "targetSize"]),
+ gradient: new Set(
+ [1, 2, 3, 4, 5].flatMap((point) => [
+ `point${point}Position`,
+ `point${point}Weight`,
+ ])
+ ),
model: new Set(["elevation", "location", "orbit", "rotation", "scale", "shift"]),
shape: new Set(["center", "rotation", "size"]),
text: new Set(["fontSize", "offset", "rotation"]),
diff --git a/src/store/layer-store.ts b/src/store/layer-store.ts
index 72b94ea4..3201e7cf 100644
--- a/src/store/layer-store.ts
+++ b/src/store/layer-store.ts
@@ -22,6 +22,7 @@ import {
import { normalizeTextFontWeight } from "@/lib/editor/text-fonts"
import { validateLayerHierarchy } from "@/renderer/layer-hierarchy"
import { useAudioStore } from "@/store/audio-store"
+import { meshGradientPresetParams, shuffleMeshGradient } from "@/lib/editor/mesh-gradient"
import { useEditorStore } from "@/store/editor-store"
import { useTimelineStore } from "@/store/timeline-store"
import type {
@@ -29,7 +30,6 @@ import type {
EditorLayer,
FluidInteractionEvent,
LayerCompositeMode,
- LayerParameterValues,
LayerType,
MaskConfig,
ParameterValue,
@@ -142,273 +142,6 @@ function getGradientNoiseDefaults(noiseType: string): {
}
}
-function getGradientPresetDefaults(
- preset: string
-): Record | null {
- switch (preset) {
- case "aurora":
- return {
- activePoints: 5,
- point1Color: "#ed6a5a",
- point1Position: [-0.8, -0.6],
- point1Weight: 1.0,
- point2Color: "#f4f1bb",
- point2Position: [0.2, 0.7],
- point2Weight: 1.0,
- point3Color: "#9bc1bc",
- point3Position: [0.9, -0.3],
- point3Weight: 1.0,
- point4Color: "#5d576b",
- point4Position: [-0.4, 0.5],
- point4Weight: 1.0,
- point5Color: "#e6ebe0",
- point5Position: [0.6, -0.8],
- point5Weight: 1.0,
- noiseType: "simplex",
- warpAmount: 0.8,
- warpScale: 4.0,
- warpIterations: 3,
- warpDecay: 1.0,
- warpBias: 0.65,
- vortexAmount: 0.3,
- falloff: 3.5,
- tonemapMode: "totos",
- glowStrength: 0.0,
- glowThreshold: 0.0,
- grainAmount: 0.08,
- vignetteStrength: 0.0,
- vignetteRadius: 1.5,
- vignetteSoftness: 1,
- }
- case "sunset":
- return {
- activePoints: 4,
- point1Color: "#1a0a2e",
- point1Position: [-0.6, -0.8],
- point1Weight: 0.8,
- point2Color: "#c4420a",
- point2Position: [0.3, 0.4],
- point2Weight: 1.2,
- point3Color: "#e8821a",
- point3Position: [0.8, 0.7],
- point3Weight: 0.9,
- point4Color: "#4a1942",
- point4Position: [-0.5, 0.3],
- point4Weight: 1.0,
- noiseType: "simplex",
- warpAmount: 0.6,
- warpScale: 3.5,
- warpIterations: 2,
- warpDecay: 1.2,
- warpBias: 0.5,
- vortexAmount: 0.0,
- falloff: 3.5,
- tonemapMode: "totos",
- glowStrength: 0.0,
- glowThreshold: 0.0,
- grainAmount: 0.08,
- vignetteStrength: 0.15,
- vignetteRadius: 1.4,
- vignetteSoftness: 0.8,
- }
- case "deep-ocean":
- return {
- activePoints: 4,
- point1Color: "#020b1a",
- point1Position: [0.0, -0.7],
- point1Weight: 0.8,
- point2Color: "#0a3d62",
- point2Position: [-0.6, 0.4],
- point2Weight: 1.2,
- point3Color: "#3c8dbc",
- point3Position: [0.7, 0.1],
- point3Weight: 0.9,
- point4Color: "#061224",
- point4Position: [0.3, 0.8],
- point4Weight: 1.0,
- noiseType: "turbulence",
- warpAmount: 0.04,
- warpScale: 0.28,
- warpIterations: 3,
- warpDecay: 0.8,
- warpBias: 0.4,
- vortexAmount: 0.35,
- falloff: 3.5,
- tonemapMode: "totos",
- glowStrength: 0.0,
- glowThreshold: 0.0,
- grainAmount: 0.06,
- vignetteStrength: 0.2,
- vignetteRadius: 1.3,
- vignetteSoftness: 0.7,
- }
- case "neon-glow":
- return {
- activePoints: 5,
- point1Color: "#0a0a0a",
- point1Position: [0.0, 0.0],
- point1Weight: 0.6,
- point2Color: "#b80050",
- point2Position: [-0.7, -0.5],
- point2Weight: 1.3,
- point3Color: "#0088aa",
- point3Position: [0.8, 0.3],
- point3Weight: 1.1,
- point4Color: "#220033",
- point4Position: [0.2, -0.8],
- point4Weight: 0.9,
- point5Color: "#1a0a2e",
- point5Position: [-0.5, 0.7],
- point5Weight: 1.0,
- noiseType: "simplex",
- warpAmount: 0.7,
- warpScale: 4.0,
- warpIterations: 3,
- warpDecay: 1.0,
- warpBias: 0.35,
- vortexAmount: -0.25,
- falloff: 3.5,
- tonemapMode: "totos",
- glowStrength: 0.0,
- glowThreshold: 0.0,
- grainAmount: 0.05,
- vignetteStrength: 0.1,
- vignetteRadius: 1.5,
- vignetteSoftness: 1,
- }
- default:
- return null
- }
-}
-
-function randomBetween(min: number, max: number): number {
- return min + Math.random() * (max - min)
-}
-
-function randomInt(min: number, max: number): number {
- return Math.floor(randomBetween(min, max + 1))
-}
-
-function clampValue(value: number, min: number, max: number): number {
- return Math.min(max, Math.max(min, value))
-}
-
-function randomChoice(values: readonly T[]): T {
- return values[Math.floor(Math.random() * values.length)] as T
-}
-
-function randomHexColor(): string {
- const hue = randomInt(0, 359)
- const saturation = randomInt(45, 95) / 100
- const lightness = randomInt(30, 78) / 100
- const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
- const hueSector = hue / 60
- const x = chroma * (1 - Math.abs((hueSector % 2) - 1))
- const match = lightness - chroma / 2
-
- let r = 0
- let g = 0
- let b = 0
-
- if (hueSector < 1) {
- r = chroma
- g = x
- } else if (hueSector < 2) {
- r = x
- g = chroma
- } else if (hueSector < 3) {
- g = chroma
- b = x
- } else if (hueSector < 4) {
- g = x
- b = chroma
- } else if (hueSector < 5) {
- r = x
- b = chroma
- } else {
- r = chroma
- b = x
- }
-
- const toHex = (channel: number) =>
- Math.round((channel + match) * 255)
- .toString(16)
- .padStart(2, "0")
-
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`
-}
-
-function buildRandomGradientParams(): LayerParameterValues {
- const params = resetLayerParameters("gradient")
- const noiseType = randomChoice([
- "simplex",
- "perlin",
- "value",
- "voronoi",
- "ridge",
- "turbulence",
- ] as const)
- const noiseDefaults = getGradientNoiseDefaults(noiseType)
-
- params.preset = "custom"
- params.activePoints = randomInt(2, 5)
-
- for (let index = 1; index <= 5; index += 1) {
- params[`point${index}Color`] = randomHexColor()
- params[`point${index}Position`] = [
- Number(randomBetween(-1.1, 1.1).toFixed(2)),
- Number(randomBetween(-1.1, 1.1).toFixed(2)),
- ]
- params[`point${index}Weight`] = Number(randomBetween(0.2, 2.4).toFixed(2))
- }
-
- params.noiseType = noiseType
- params.noiseSeed = Number(randomBetween(0, 100).toFixed(1))
- params.warpAmount = Number(
- clampValue(
- randomBetween(
- (noiseDefaults?.warpAmount ?? 0.3) * 0.6,
- (noiseDefaults?.warpAmount ?? 0.3) * 1.4 + 0.08
- ),
- 0,
- 1
- ).toFixed(2)
- )
- params.warpScale = Number(
- clampValue(
- randomBetween(
- (noiseDefaults?.warpScale ?? 2.5) * 0.6,
- (noiseDefaults?.warpScale ?? 2.5) * 1.4 + 0.2
- ),
- 0.1,
- 6
- ).toFixed(2)
- )
- params.warpIterations = randomInt(1, 4)
- params.warpDecay = Number(randomBetween(0.4, 1.8).toFixed(2))
- params.warpBias = Number(randomBetween(0, 1).toFixed(2))
- params.vortexAmount = Number(randomBetween(-1, 1).toFixed(2))
- params.animate = Math.random() > 0.2
- params.motionAmount = Number(randomBetween(0, 1).toFixed(2))
- params.motionSpeed = Number(randomBetween(0, 2).toFixed(2))
- params.falloff = Number(randomBetween(0.8, 3.8).toFixed(2))
- params.tonemapMode = randomChoice([
- "none",
- "aces",
- "reinhard",
- "totos",
- "cinematic",
- ] as const)
- params.glowStrength = Number(randomBetween(0, 0.45).toFixed(2))
- params.glowThreshold = Number(randomBetween(0, 0.6).toFixed(2))
- params.grainAmount = Number(randomBetween(0, 0.25).toFixed(2))
- params.vignetteStrength = Number(randomBetween(0, 0.35).toFixed(2))
- params.vignetteRadius = Number(randomBetween(0.6, 1.5).toFixed(2))
- params.vignetteSoftness = Number(randomBetween(0.2, 1).toFixed(2))
-
- return params
-}
-
function getDitheringPresetDefaults(
preset: string
): Record | null {
@@ -1033,7 +766,7 @@ export const useLayerStore = create((set, get) => ({
layer.id === id && layer.type === "gradient"
? {
...layer,
- params: buildRandomGradientParams(),
+ params: { ...layer.params, ...shuffleMeshGradient() },
runtimeError: null,
}
: layer
@@ -1098,7 +831,7 @@ export const useLayerStore = create((set, get) => ({
key === "preset" &&
typeof value === "string"
) {
- const defaults = getGradientPresetDefaults(value)
+ const defaults = meshGradientPresetParams(value)
if (defaults) {
Object.assign(nextParams, defaults)