From 6b6c2ebb80e4bcf21c07f13bca7e0d004150e00b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 4 Jul 2026 11:13:47 +0200 Subject: [PATCH] feat: add chroma key tool and settings, including UI components and controls --- commands/ids.ts | 1 + commands/index.ts | 4 +- commands/payloads.ts | 3 +- commands/tool.test.ts | 13 +- commands/tool.ts | 30 +- editor/index.ts | 2 +- editor/tools.ts | 14 +- input/tool-keybinds.ts | 1 + input/transform-controls.ts | 2 +- view/App.tsx | 5 +- view/BottomControlsIsland.tsx | 13 +- view/ShortcutsDisplay.tsx | 1 + view/ToolOverlay.tsx | 4 +- view/bottom-controls/ChromaKeyControls.tsx | 382 +++++++++++++++++++++ view/canvas/cursor.ts | 1 + view/toolLabels.ts | 2 + 16 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 view/bottom-controls/ChromaKeyControls.tsx diff --git a/commands/ids.ts b/commands/ids.ts index bee3070..f917e8c 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -25,6 +25,7 @@ export const commandIds = { selectionAddLayer: "selection.addLayer", toolSetActive: "tool.setActive", toolSetBrushSettings: "tool.setBrushSettings", + toolSetChromaKeySettings: "tool.setChromaKeySettings", toolSetBrushPreview: "tool.setBrushPreview", toolSetBrushStrokePreview: "tool.setBrushStrokePreview", toolSetMaskViewMode: "tool.setMaskViewMode", diff --git a/commands/index.ts b/commands/index.ts index 5755fea..71ef440 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -54,10 +54,10 @@ export type { CommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool"; +export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool"; export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; -export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool"; +export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool"; export { viewportCommands, viewportPanCommand, diff --git a/commands/payloads.ts b/commands/payloads.ts index 3de400a..0f9eaff 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -23,7 +23,7 @@ import type { DocumentUngroupLayerPayload, } from "./document"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool"; +import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { ViewportFitArtboardPayload, @@ -60,6 +60,7 @@ export type CommandPayloads = { [commandIds.selectionAddLayer]: SelectionAddLayerPayload; [commandIds.toolSetActive]: ToolSetActivePayload; [commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload; + [commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload; [commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload; [commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload; [commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload; diff --git a/commands/tool.test.ts b/commands/tool.test.ts index 6ee177c..0dfbc05 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "bun:test"; import { createInitialAppState } from "@editor/initial-state"; -import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool"; +import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool"; const defaultBrush = { color: "#111827", size: 8, hardness: 100 }; +const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }; describe("tool commands", () => { test("sets active tool", () => { const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" }); - expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush }); + expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey }); }); test("sets brush settings", () => { @@ -16,6 +17,12 @@ describe("tool commands", () => { expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 }); }); + test("sets chroma key settings", () => { + const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 }); + + expect(next.editor.tools.chromaKey).toEqual({ color: "#123456", tolerance: 255, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }); + }); + test("sets and clears brush preview", () => { const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } }); const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined); @@ -56,7 +63,7 @@ describe("tool commands", () => { const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined); const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined); - expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush }); + expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey }); expect(restored.editor.tools).toEqual(initial.editor.tools); }); }); diff --git a/commands/tool.ts b/commands/tool.ts index ad252ac..d0d9e39 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -3,7 +3,7 @@ import type { Vec2D } from "@core/geometry"; import type { LayerId, ArtboardId, AssetId } from "@core/id"; import type { Layer } from "@core/layer"; import type { MaskViewMode } from "@editor/state"; -import type { BrushSettings, ToolId } from "@editor/tools"; +import type { BrushSettings, ChromaKeySettings, ToolId } from "@editor/tools"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -13,6 +13,8 @@ export type ToolSetActivePayload = { export type ToolSetBrushSettingsPayload = Partial; +export type ToolSetChromaKeySettingsPayload = Partial; + export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined; export type ToolSetBrushStrokePreviewPayload = @@ -73,6 +75,31 @@ export const toolSetBrushSettingsCommand: Command = }, }; +export const toolSetChromaKeySettingsCommand: Command = { + id: commandIds.toolSetChromaKeySettings, + name: "Set chroma key settings", + execute({ state }, payload) { + return { + ...state, + editor: { + ...state.editor, + tools: { + ...state.editor.tools, + chromaKey: { + color: payload.color ?? state.editor.tools.chromaKey.color, + tolerance: clampNumber(payload.tolerance ?? state.editor.tools.chromaKey.tolerance, 0, 255), + softness: clampNumber(payload.softness ?? state.editor.tools.chromaKey.softness, 0, 255), + feather: clampNumber(payload.feather ?? state.editor.tools.chromaKey.feather, 0, 20), + choke: clampNumber(payload.choke ?? state.editor.tools.chromaKey.choke, -20, 20), + despeckle: clampNumber(payload.despeckle ?? state.editor.tools.chromaKey.despeckle, 0, 20), + spill: clampNumber(payload.spill ?? state.editor.tools.chromaKey.spill, 0, 100), + }, + }, + }, + }; + }, +}; + export const toolSetBrushPreviewCommand: Command = { id: commandIds.toolSetBrushPreview, name: "Set brush preview", @@ -222,6 +249,7 @@ export const toolExitTemporaryPanCommand: Command = { export const toolCommands = [ toolSetActiveCommand, toolSetBrushSettingsCommand, + toolSetChromaKeySettingsCommand, toolSetBrushPreviewCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand, diff --git a/editor/index.ts b/editor/index.ts index 403f654..a9c8e3d 100644 --- a/editor/index.ts +++ b/editor/index.ts @@ -1,5 +1,5 @@ export type { AppState, EditorState, SelectionState, ViewportState } from "./state"; -export type { InteractionMode, ToolId, ToolState } from "./tools"; +export type { BrushSettings, ChromaKeySettings, InteractionMode, ToolId, ToolState } from "./tools"; export { initialToolState } from "./tools"; export { createInitialAppState, initialEditorState } from "./initial-state"; export type { AppStore, StateListener } from "./store"; diff --git a/editor/tools.ts b/editor/tools.ts index d4412f3..3302ff3 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -1,4 +1,4 @@ -export const availableToolIds = ["select", "brush", "eraser", "pan"] as const; +export const availableToolIds = ["select", "brush", "eraser", "chromaKey", "pan"] as const; export type ToolId = (typeof availableToolIds)[number]; @@ -12,16 +12,28 @@ export type BrushSettings = { hardness: number; }; +export type ChromaKeySettings = { + color: string; + tolerance: number; + softness: number; + feather: number; + choke: number; + despeckle: number; + spill: number; +}; + export type ToolState = { activeTool: ToolId; interactionMode: InteractionMode; brush: BrushSettings; + chromaKey: ChromaKeySettings; }; export const initialToolState: ToolState = { activeTool: "select", interactionMode: { type: "tool", tool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 }, + chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }, }; export function isPanInteractionMode(interactionMode: InteractionMode): boolean { diff --git a/input/tool-keybinds.ts b/input/tool-keybinds.ts index 50d2b06..dfdcdd4 100644 --- a/input/tool-keybinds.ts +++ b/input/tool-keybinds.ts @@ -5,6 +5,7 @@ import type { KeybindEvent } from "./keyboard"; const toolKeybinds = { b: "brush", e: "eraser", + k: "chromaKey", p: "pan", s: "select", } as const; diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 8a38199..444e53d 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer"; type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; -type InputToolId = "select" | "brush" | "eraser" | "pan"; +type InputToolId = "select" | "brush" | "eraser" | "chromaKey" | "pan"; type InputInteractionMode = | { type: "tool"; tool: InputToolId } diff --git a/view/App.tsx b/view/App.tsx index 7a90468..e14af8b 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -112,11 +112,14 @@ export function App({ app }: AppProps) { />
) : activeTool === "brush" || activeTool === "eraser" ? ( + ) : activeTool === "chromaKey" ? ( + ) : transformBounds && transformTarget ? ( ) : action === "pan" ? ( diff --git a/view/ShortcutsDisplay.tsx b/view/ShortcutsDisplay.tsx index 52de658..588e200 100644 --- a/view/ShortcutsDisplay.tsx +++ b/view/ShortcutsDisplay.tsx @@ -10,6 +10,7 @@ const shortcuts: Shortcut[] = [ { keys: ["S"], label: "Select" }, { keys: ["B"], label: "Brush" }, { keys: ["E"], label: "Eraser" }, + { keys: ["K"], label: "Chroma key" }, { keys: ["P"], label: "Pan" }, { keys: ["Space"], label: "Hold to pan" }, { keys: ["L"], label: "Layers" }, diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index 1b48de3..e482040 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -1,4 +1,4 @@ -import { Cursor, Eraser, Hand, PaintBrush } from "@phosphor-icons/react"; +import { Cursor, Eraser, Hand, PaintBrush, DropHalf } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; import type { InteractionMode, ToolId } from "@editor/tools"; @@ -45,6 +45,8 @@ function iconForTool(tool: ToolId) { return PaintBrush; case "eraser": return Eraser; + case "chromaKey": + return DropHalf; case "pan": return Hand; case "select": diff --git a/view/bottom-controls/ChromaKeyControls.tsx b/view/bottom-controls/ChromaKeyControls.tsx new file mode 100644 index 0000000..e2e43a5 --- /dev/null +++ b/view/bottom-controls/ChromaKeyControls.tsx @@ -0,0 +1,382 @@ +import { useEffect } from "react"; +import { DropHalf } from "@phosphor-icons/react"; +import { commandIds } from "@commands/ids"; +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; +import type { AppStore } from "@editor/store"; +import type { ChromaKeySettings } from "@editor/tools"; +import type { SelectionState } from "@editor/state"; +import { BottomControlColorPicker } from "./ColorPicker"; +import { BottomControlDivider } from "./Divider"; +import { BottomControlSlider } from "./Slider"; +import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles"; + +export type ChromaKeyControlsProps = { + document: ImageDocument; + selection: SelectionState; + settings: ChromaKeySettings; + dispatch: AppStore["dispatch"]; +}; + +export function ChromaKeyControls({ document, selection, settings, dispatch }: ChromaKeyControlsProps) { + const target = resolveChromaKeyTarget(document, selection); + + useEffect(() => { + return () => { + dispatch(commandIds.toolSetBrushStrokePreview, undefined); + }; + }, [dispatch]); + + useEffect(() => { + let cancelled = false; + + if (!target) { + dispatch(commandIds.toolSetBrushStrokePreview, undefined); + return; + } + + void chromaKeySource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => { + if (cancelled) return; + dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source }); + }); + + return () => { + cancelled = true; + }; + }, [dispatch, settings.choke, settings.color, settings.despeckle, settings.feather, settings.softness, settings.spill, settings.tolerance, target?.asset.id, target?.asset.source, target?.asset.intrinsicSize.w, target?.asset.intrinsicSize.h, target?.layer.id]); + + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} + +function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionState) { + const layerId = selection.layerIds[0]; + if (selection.layerIds.length !== 1 || !layerId) return undefined; + const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); + if (!layer || layer.type === "group") return undefined; + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; + const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; +} + +function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const found = findLayer(layer.children, layerId); + if (found) return found; + } + } + return undefined; +} + +async function applyChromaKeyMask(target: NonNullable>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) { + const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings); + dispatch(commandIds.toolSetBrushStrokePreview, undefined); + + if (target.maskAsset) { + dispatch(commandIds.documentUpdateAssetSource, { assetId: target.maskAsset.id, source }); + return; + } + + const assetId = crypto.randomUUID(); + const maskLayerId = crypto.randomUUID(); + const width = Math.max(1, Math.round(target.asset.intrinsicSize.w)); + const height = Math.max(1, Math.round(target.asset.intrinsicSize.h)); + dispatch(commandIds.documentAddLayerMask, { + layerId: target.layer.id, + asset: { + id: assetId, + name: `${target.layer.name} Chroma Mask`, + mimeType: "image/png", + source, + intrinsicSize: { w: width, h: height }, + }, + maskLayer: { + id: maskLayerId, + type: "raster", + name: `${target.layer.name} Chroma Mask`, + visible: true, + locked: false, + opacity: 1, + assetId, + transform: { + position: { x: target.bounds.x, y: target.bounds.y }, + scale: { x: target.bounds.w / width, y: target.bounds.h / height }, + rotation: target.layer.transform.rotation, + }, + }, + }); + dispatch(commandIds.toolExitMaskEdit, undefined); + dispatch(commandIds.toolSetActive, { tool: "chromaKey" }); +} + +async function chromaKeySource(source: string, width: number, height: number, settings: ChromaKeySettings) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); + if (!context) return source; + const image = await loadImage(source); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings); + for (let pixel = 0; pixel < alpha.length; pixel++) data.data[pixel * 4 + 3] = alpha[pixel] ?? 255; + context.putImageData(data, 0, 0); + return canvas.toDataURL("image/png"); +} + +async function chromaKeyMaskSource(source: string, width: number, height: number, settings: ChromaKeySettings) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); + if (!context) return source; + const image = await loadImage(source); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings); + + for (let pixel = 0; pixel < alpha.length; pixel++) { + const index = pixel * 4; + data.data[index] = 255; + data.data[index + 1] = 255; + data.data[index + 2] = 255; + data.data[index + 3] = alpha[pixel] ?? 255; + } + + context.putImageData(data, 0, 0); + return canvas.toDataURL("image/png"); +} + +function hexToRgb(color: string) { + const hex = color.replace("#", ""); + return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) }; +} + +function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) { + const key = hexToRgb(settings.color); + const alpha = new Uint8ClampedArray(width * height); + for (let pixel = 0; pixel < alpha.length; pixel++) { + const index = pixel * 4; + const red = data.data[index] ?? 0; + const green = data.data[index + 1] ?? 0; + const blue = data.data[index + 2] ?? 0; + const sourceAlpha = data.data[index + 3] ?? 255; + alpha[pixel] = Math.round(sourceAlpha * chromaKeyKeepFactor(red, green, blue, key, settings)); + } + return postProcessAlpha(alpha, width, height, settings); +} + +function chromaKeyKeepFactor(red: number, green: number, blue: number, key: { r: number; g: number; b: number }, settings: ChromaKeySettings) { + const tolerance = Math.max(0, Math.min(255, settings.tolerance)); + const softness = Math.max(0, Math.min(255, settings.softness)); + const spill = Math.max(0, Math.min(100, settings.spill)) / 100; + const distance = Math.hypot(red - key.r, green - key.g, blue - key.b); + const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1; + if (spill <= 0) return edgeKeep; + + const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue; + const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green); + const spillAmount = Math.max(0, dominant - neutral) / 255; + return Math.max(0, Math.min(edgeKeep, 1 - spillAmount * spill)); +} + +function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: number, settings: ChromaKeySettings) { + let next = alpha; + const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle))); + const choke = Math.round(Math.max(-20, Math.min(20, settings.choke))); + const feather = Math.round(Math.max(0, Math.min(20, settings.feather))); + + if (despeckle > 0) next = despeckleAlpha(next, width, height, despeckle); + if (choke > 0) next = erodeAlpha(next, width, height, choke); + if (choke < 0) next = dilateAlpha(next, width, height, -choke); + if (feather > 0) next = blurAlpha(next, width, height, feather); + return next; +} + +function despeckleAlpha(alpha: Uint8ClampedArray, width: number, height: number, strength: number) { + const radius = Math.max(1, Math.ceil(strength / 6)); + const threshold = Math.max(1, Math.round(strength / 2)); + const next = new Uint8ClampedArray(alpha); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x; + const visible = (alpha[index] ?? 0) > 127; + let same = 0; + for (let oy = -radius; oy <= radius; oy++) { + for (let ox = -radius; ox <= radius; ox++) { + if (ox === 0 && oy === 0) continue; + const sample = alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0; + if ((sample > 127) === visible) same += 1; + } + } + if (same <= threshold) next[index] = visible ? 0 : 255; + } + } + return next; +} + +function erodeAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) { + const next = new Uint8ClampedArray(alpha.length); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let value = 255; + for (let oy = -radius; oy <= radius; oy++) { + for (let ox = -radius; ox <= radius; ox++) value = Math.min(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0); + } + next[y * width + x] = value; + } + } + return next; +} + +function dilateAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) { + const next = new Uint8ClampedArray(alpha.length); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let value = 0; + for (let oy = -radius; oy <= radius; oy++) { + for (let ox = -radius; ox <= radius; ox++) value = Math.max(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0); + } + next[y * width + x] = value; + } + } + return next; +} + +function blurAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) { + const next = new Uint8ClampedArray(alpha.length); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let total = 0; + let count = 0; + for (let oy = -radius; oy <= radius; oy++) { + for (let ox = -radius; ox <= radius; ox++) { + total += alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0; + count += 1; + } + } + next[y * width + x] = Math.round(total / count); + } + } + return next; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function loadImage(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +} diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 304ed78..202c94b 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -9,5 +9,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, input: Canva if (!canBrush) return "cursor-not-allowed"; return hasBrushPreview ? "cursor-none" : "cursor-crosshair"; } + if (interactionMode.type === "tool" && interactionMode.tool === "chromaKey") return "cursor-crosshair"; return "cursor-default"; } diff --git a/view/toolLabels.ts b/view/toolLabels.ts index 98c6507..3e54939 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -4,6 +4,8 @@ export function labelForTool(tool: ToolId): string { switch (tool) { case "brush": return "Brush"; + case "chromaKey": + return "Chroma key"; case "eraser": return "Eraser"; case "pan":