import { clamp01 } from "./color.js"; const CHANNEL_COLORS = { rgb: "#ffffff", r: "#ff7070", g: "#70ffa0", b: "#72a0ff" }; export class CurveEditor { constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) { this.canvas = canvas; this.ctx = canvas.getContext("2d"); this.channelButtons = channelButtons; this.resetButton = resetButton; this.onChange = onChange; this.onCommit = onCommit; this.channels = ["rgb", "r", "g", "b"]; this.activeChannel = "rgb"; this.curves = this.createDefaultCurves(); this.curveTangents = {}; this.channels.forEach(channel => (this.curveTangents[channel] = [])); this.luts = this.buildAllLUTs(); this.isDragging = false; this.dragIndex = null; this.curveDirty = false; this.displayWidth = this.canvas.clientWidth || 240; this.displayHeight = this.canvas.clientHeight || 240; this.resizeObserver = null; this.handleResize = this.handleResize.bind(this); this.onPointerDown = this.onPointerDown.bind(this); this.onPointerMove = this.onPointerMove.bind(this); this.onPointerUp = this.onPointerUp.bind(this); this.onDoubleClick = this.onDoubleClick.bind(this); window.addEventListener("resize", this.handleResize); this.canvas.addEventListener("mousedown", this.onPointerDown); window.addEventListener("mousemove", this.onPointerMove); window.addEventListener("mouseup", this.onPointerUp); this.canvas.addEventListener("dblclick", this.onDoubleClick); this.attachChannelButtons(); this.attachResetButton(); this.handleResize(); if (window.ResizeObserver) { this.resizeObserver = new ResizeObserver(() => this.handleResize()); this.resizeObserver.observe(this.canvas); } this.draw(); } destroy() { this.resizeObserver?.disconnect(); window.removeEventListener("resize", this.handleResize); this.canvas.removeEventListener("mousedown", this.onPointerDown); window.removeEventListener("mousemove", this.onPointerMove); window.removeEventListener("mouseup", this.onPointerUp); this.canvas.removeEventListener("dblclick", this.onDoubleClick); } attachChannelButtons() { this.channelButtons.forEach(btn => { btn.addEventListener("click", () => { const channel = btn.dataset.curveChannel; if (channel && this.channels.includes(channel)) { this.activeChannel = channel; this.updateChannelButtons(); this.draw(); } }); }); this.updateChannelButtons(); } attachResetButton() { if (!this.resetButton) return; this.resetButton.addEventListener("click", () => { this.resetChannel(this.activeChannel); this.notifyChange(); this.notifyCommit(); }); } updateChannelButtons() { this.channelButtons.forEach(btn => { const channel = btn.dataset.curveChannel; btn.classList.toggle("active", channel === this.activeChannel); }); } handleResize() { const rect = this.canvas.getBoundingClientRect(); const width = Math.max(1, rect.width || 240); const height = Math.max(1, rect.height || 240); this.displayWidth = width; this.displayHeight = height; const dpr = window.devicePixelRatio || 1; this.canvas.width = width * dpr; this.canvas.height = height * dpr; this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(dpr, dpr); this.draw(); } createDefaultCurve() { return [ { x: 0, y: 0 }, { x: 1, y: 1 } ]; } createDefaultCurves() { return { rgb: this.createDefaultCurve().map(p => ({ ...p })), r: this.createDefaultCurve().map(p => ({ ...p })), g: this.createDefaultCurve().map(p => ({ ...p })), b: this.createDefaultCurve().map(p => ({ ...p })) }; } cloneCurves(source = this.curves) { const clone = {}; this.channels.forEach(channel => { clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y })); }); return clone; } setState(state) { if (!state) return; const incoming = this.cloneCurves(state.curves || {}); this.curves = incoming; if (state.activeChannel && this.channels.includes(state.activeChannel)) { this.activeChannel = state.activeChannel; } this.rebuildAllLUTs(); this.updateChannelButtons(); this.draw(); } getState() { return { curves: this.cloneCurves(), activeChannel: this.activeChannel }; } resetChannel(channel, emit = false) { if (!this.channels.includes(channel)) return; this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); this.rebuildChannelLUT(channel); this.draw(); if (emit) { this.notifyChange(); this.notifyCommit(); this.curveDirty = false; } } resetAll(emit = true) { this.channels.forEach(channel => { this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); }); this.rebuildAllLUTs(); this.draw(); if (emit) { this.notifyChange(); this.notifyCommit(); this.curveDirty = false; } } hasAdjustments() { return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel])); } isDefaultCurve(curve) { if (!curve || curve.length !== 2) return false; const [start, end] = curve; const epsilon = 0.0001; return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon && Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon; } notifyChange() { this.onChange?.(); } notifyCommit() { this.onCommit?.(); } getLUTPack() { return { rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb, r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r, g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g, b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b, hasAdjustments: this.hasAdjustments() }; } buildAllLUTs() { const result = {}; this.channels.forEach(channel => { const curve = this.curves[channel]; const tangents = this.computeTangents(curve); this.curveTangents[channel] = tangents; result[channel] = this.buildCurveLUT(curve, tangents); }); return result; } rebuildAllLUTs() { this.luts = this.buildAllLUTs(); } rebuildChannelLUT(channel) { const curve = this.curves[channel]; const tangents = this.computeTangents(curve); this.curveTangents[channel] = tangents; this.luts[channel] = this.buildCurveLUT(curve, tangents); } buildCurveLUT(curve, tangents = null) { const curveTangents = tangents || this.computeTangents(curve); const lut = new Uint8ClampedArray(256); for (let i = 0; i < 256; i++) { const pos = i / 255; lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255); } return lut; } computeTangents(curve) { const n = curve.length; if (n < 2) return new Array(n).fill(0); const tangents = new Array(n).fill(0); const delta = new Array(n - 1).fill(0); const dx = new Array(n - 1).fill(0); for (let i = 0; i < n - 1; i++) { dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x); delta[i] = (curve[i + 1].y - curve[i].y) / dx[i]; } tangents[0] = delta[0]; tangents[n - 1] = delta[n - 2]; for (let i = 1; i < n - 1; i++) { if (delta[i - 1] * delta[i] <= 0) { tangents[i] = 0; } else { const w1 = 2 * dx[i] + dx[i - 1]; const w2 = dx[i] + 2 * dx[i - 1]; tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]); } } for (let i = 0; i < n - 1; i++) { if (Math.abs(delta[i]) < 1e-6) { tangents[i] = 0; tangents[i + 1] = 0; } else { let alpha = tangents[i] / delta[i]; let beta = tangents[i + 1] / delta[i]; const sum = alpha * alpha + beta * beta; if (sum > 9) { const tau = 3 / Math.sqrt(sum); alpha *= tau; beta *= tau; tangents[i] = alpha * delta[i]; tangents[i + 1] = beta * delta[i]; } } } return tangents; } sampleSmoothCurve(curve, t, tangents) { if (!curve || curve.length === 0) return t; const n = curve.length; if (!tangents || tangents.length !== n) { tangents = this.computeTangents(curve); } if (t <= curve[0].x) return curve[0].y; if (t >= curve[n - 1].x) return curve[n - 1].y; let idx = 1; for (; idx < n; idx++) { if (t <= curve[idx].x) break; } const p0 = curve[idx - 1]; const p1 = curve[idx]; const m0 = tangents[idx - 1] ?? 0; const m1 = tangents[idx] ?? 0; const span = p1.x - p0.x || 1e-6; const u = (t - p0.x) / span; const h00 = (2 * u ** 3) - (3 * u ** 2) + 1; const h10 = u ** 3 - 2 * u ** 2 + u; const h01 = (-2 * u ** 3) + (3 * u ** 2); const h11 = u ** 3 - u ** 2; const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1; return clamp01(value); } getActiveCurve() { return this.curves[this.activeChannel]; } addPoint(x, y) { const points = this.getActiveCurve(); let insertIndex = points.findIndex(point => x < point.x); if (insertIndex === -1) { points.push({ x, y }); insertIndex = points.length - 1; } else { points.splice(insertIndex, 0, { x, y }); } this.rebuildChannelLUT(this.activeChannel); this.draw(); this.curveDirty = true; this.notifyChange(); return insertIndex; } updatePoint(index, x, y) { const points = this.getActiveCurve(); const point = points[index]; if (!point) return; const originalX = point.x; const originalY = point.y; if (index === 0) { point.x = 0; point.y = clamp01(y); } else if (index === points.length - 1) { point.x = 1; point.y = clamp01(y); } else { const minX = points[index - 1].x + 0.01; const maxX = points[index + 1].x - 0.01; point.x = clamp01(Math.min(Math.max(x, minX), maxX)); point.y = clamp01(y); } if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) { return; } this.rebuildChannelLUT(this.activeChannel); this.draw(); this.curveDirty = true; this.notifyChange(); } removePoint(index) { const points = this.getActiveCurve(); if (index <= 0 || index >= points.length - 1) return; points.splice(index, 1); this.rebuildChannelLUT(this.activeChannel); this.draw(); this.notifyChange(); this.notifyCommit(); this.curveDirty = false; } getPointerPosition(event) { const rect = this.canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const x = clamp01((event.clientX - rect.left) / rect.width); const y = clamp01(1 - (event.clientY - rect.top) / rect.height); return { x, y }; } findPointIndex(pos, threshold = 10) { if (!pos) return -1; const points = this.getActiveCurve(); const targetX = pos.x * this.displayWidth; const targetY = (1 - pos.y) * this.displayHeight; for (let i = 0; i < points.length; i++) { const pt = points[i]; const px = pt.x * this.displayWidth; const py = (1 - pt.y) * this.displayHeight; const dist = Math.hypot(px - targetX, py - targetY); if (dist <= threshold) return i; } return -1; } onPointerDown(event) { if (event.button !== 0) return; const pos = this.getPointerPosition(event); if (!pos) return; event.preventDefault(); let idx = this.findPointIndex(pos); if (idx === -1) { idx = this.addPoint(pos.x, pos.y); } this.dragIndex = idx; this.isDragging = true; this.updatePoint(idx, pos.x, pos.y); } onPointerMove(event) { if (!this.isDragging || this.dragIndex === null) return; const pos = this.getPointerPosition(event); if (!pos) return; event.preventDefault(); this.updatePoint(this.dragIndex, pos.x, pos.y); } onPointerUp() { if (!this.isDragging) return; this.isDragging = false; this.dragIndex = null; if (this.curveDirty) { this.curveDirty = false; this.notifyCommit(); } } onDoubleClick(event) { const pos = this.getPointerPosition(event); if (!pos) return; const idx = this.findPointIndex(pos, 8); if (idx > 0 && idx < this.getActiveCurve().length - 1) { this.removePoint(idx); } } getChannelColor() { return CHANNEL_COLORS[this.activeChannel] || "#ffffff"; } draw() { if (!this.ctx) return; const ctx = this.ctx; const w = this.displayWidth; const h = this.displayHeight; ctx.clearRect(0, 0, w, h); this.drawGrid(ctx, w, h); this.drawCurve(ctx, w, h); this.drawPoints(ctx, w, h); } drawGrid(ctx, w, h) { ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const x = (w / 4) * i; const y = (h / 4) * i; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } } drawCurve(ctx, w, h) { const points = this.getActiveCurve(); if (!points?.length) return; const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points); ctx.strokeStyle = this.getChannelColor(); ctx.lineWidth = 2; ctx.beginPath(); const steps = 128; for (let i = 0; i <= steps; i++) { const t = i / steps; const value = this.sampleSmoothCurve(points, t, tangents); const x = t * w; const y = (1 - value) * h; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } drawPoints(ctx, w, h) { const points = this.getActiveCurve(); ctx.fillStyle = "#000"; ctx.lineWidth = 2; ctx.strokeStyle = this.getChannelColor(); points.forEach(pt => { const x = pt.x * w; const y = (1 - pt.y) * h; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }); } }