From 7a06e0db7fda5fbe3e02b22743817b0e648714ec Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Wed, 15 Apr 2026 08:49:54 +0200 Subject: [PATCH] feat(canvas): enhance mixer node functionality with overlay dimensions and cropping support --- .../__tests__/use-canvas-connections.test.tsx | 68 ++++++ components/canvas/use-canvas-connections.ts | 35 +++- .../2026-04-14-mixer-resize-crop-design.md | 92 ++++++++ docs/plans/2026-04-14-mixer-resize-crop.md | 198 ++++++++++++++++++ 4 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-04-14-mixer-resize-crop-design.md create mode 100644 docs/plans/2026-04-14-mixer-resize-crop.md diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index 2ea66be..ec0e65f 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -543,6 +543,74 @@ describe("useCanvasConnections", () => { expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); }); + it("rejects self-drops on a note instead of auto-splitting its incoming edge", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-note", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 260, clientY: 160 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-note", type: "note" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 260, y: 160 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runSplitEdgeAtExistingNodeMutation).not.toHaveBeenCalled(); + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); + }); + it("rejects text to ai-video body drops", async () => { const runCreateEdgeMutation = vi.fn(async () => undefined); const showConnectionRejectedToast = vi.fn(); diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index 3a98b31..ce840b4 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -414,7 +414,7 @@ export function useCanvasConnections({ !isOptimisticEdgeId(edge.id), ); const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined; - const splitValidationError = + const shouldAttemptAutoSplit = validationError === "adjustment-incoming-limit" && droppedConnection.sourceNodeId === fromNode.id && fromHandle.type === "source" && @@ -424,16 +424,31 @@ export function useCanvasConnections({ hasHandleKey(splitHandles, "target") && incomingEdge !== undefined && incomingEdge.source !== fullFromNode.id && - incomingEdge.target !== fullFromNode.id - ? validateCanvasEdgeSplit({ - nodes: nodesRef.current, - edges: edgesRef.current, - splitEdge: incomingEdge, - middleNode: fullFromNode, - }) - : null; + incomingEdge.target !== fullFromNode.id; + const splitValidationError = shouldAttemptAutoSplit + ? validateCanvasEdgeSplit({ + nodes: nodesRef.current, + edges: edgesRef.current, + splitEdge: incomingEdge, + middleNode: fullFromNode, + }) + : null; - if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) { + logCanvasConnectionDebug("connect:end-auto-split-eval", { + point: pt, + flow, + droppedConnection, + validationError, + shouldAttemptAutoSplit, + splitValidationError, + fromNodeId: fromNode.id, + fromNodeType: fullFromNode?.type ?? null, + incomingEdgeId: incomingEdge?.id ?? null, + incomingEdgeSourceNodeId: incomingEdge?.source ?? null, + incomingEdgeTargetNodeId: incomingEdge?.target ?? null, + }); + + if (shouldAttemptAutoSplit && !splitValidationError && incomingEdge && fullFromNode && splitHandles) { logCanvasConnectionDebug("connect:end-auto-split", { point: pt, flow, diff --git a/docs/plans/2026-04-14-mixer-resize-crop-design.md b/docs/plans/2026-04-14-mixer-resize-crop-design.md new file mode 100644 index 0000000..24b670b --- /dev/null +++ b/docs/plans/2026-04-14-mixer-resize-crop-design.md @@ -0,0 +1,92 @@ +# Mixer Resize/Crop Design + +**Goal:** Make mixer overlay resize behave like proportional image scaling, and make crop behave like classic edge-based trimming without changing displayed image size. + +## Approved Interaction Model + +- `Resize` changes the displayed overlay size only. +- `Resize` keeps aspect ratio locked. +- `Crop` changes only the visible source region. +- `Crop` does not change the displayed overlay frame size. +- `Crop` uses 8 handles: 4 corners and 4 side-midpoints. +- Dragging inside the crop box repositions the crop region. + +## Conceptual Split + +### 1. Display Frame + +Controls where and how large the overlay appears in the mixer. + +- `overlayX` +- `overlayY` +- `overlayWidth` +- `overlayHeight` + +These fields represent the displayed overlay frame in mixer preview/output space. + +### 2. Source Crop Region + +Controls which part of the source image is shown inside that frame. + +Recommended crop contract: + +- `cropLeft` +- `cropTop` +- `cropRight` +- `cropBottom` + +All values are normalized source-image trims from the corresponding edge. + +Why this model: + +- Left handle changes only `cropLeft` +- Top handle changes only `cropTop` +- Corner handles combine two crop edges +- The mental model exactly matches "take content away from edges" + +## Rendering Semantics + +Preview, compare, and bake must all use the same mapping: + +1. Resolve source image. +2. Apply crop trims to derive the sampled source rect. +3. Draw that sampled rect into the displayed overlay frame. + +This removes the ambiguous zoom-like behavior from crop mode. + +## UX Rules + +### Resize Mode + +- Handles are anchored to the display frame. +- Corner drag scales proportionally. +- Side handles are either hidden or mapped to proportional scaling from the nearest axis while preserving aspect ratio. +- Resize never mutates crop fields. + +### Crop Mode + +- Handles are anchored to the crop box. +- Edge handles trim one side. +- Corner handles trim two sides. +- Drag inside crop box repositions the crop window. +- Crop never mutates display frame size. + +## Constraints + +- Minimum display size must keep handles usable. +- Minimum crop region must prevent inverted or zero-area crop boxes. +- Crop box stays within source bounds. +- Display frame stays within mixer preview bounds. + +## Backward Compatibility + +- Existing mixer nodes with `contentX/Y/Width/Height` need a migration/default path. +- If a direct field migration is too risky, normalization can temporarily map legacy content fields into equivalent crop trims. + +## Non-Goals + +- rotation +- masks +- free distortion +- multi-layer cropping +- standalone crop modal diff --git a/docs/plans/2026-04-14-mixer-resize-crop.md b/docs/plans/2026-04-14-mixer-resize-crop.md new file mode 100644 index 0000000..7cf6442 --- /dev/null +++ b/docs/plans/2026-04-14-mixer-resize-crop.md @@ -0,0 +1,198 @@ +# Mixer Resize/Crop Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Correct mixer interactions so resize scales the overlay proportionally while crop trims visible content from any side without changing displayed image size. + +**Architecture:** Split displayed overlay geometry from source crop geometry. Keep `overlayX/Y/Width/Height` for display frame placement and size. Introduce explicit crop-edge semantics so preview, compare, and bake all trim the same source region and map it into the unchanged frame. + +**Tech Stack:** Next.js 16, React 19, `@xyflow/react`, Vitest, local node preview state via `useNodeLocalData`, image pipeline source-loader. + +--- + +### Task 1: Add failing tests for the approved resize/crop semantics + +**Files:** +- Modify: `components/canvas/__tests__/mixer-node.test.tsx` +- Modify: `tests/image-pipeline/source-loader.test.ts` +- Modify: `tests/lib/canvas-mixer-preview.test.ts` + +**Step 1: Write the failing tests** + +Add tests that prove: + +- frame resize keeps aspect ratio locked +- crop handle drag trims edges without changing displayed overlay frame size +- crop drag inside crop box repositions crop region only +- resize does not mutate crop fields +- crop does not mutate `overlayWidth` / `overlayHeight` + +**Step 2: Run tests to verify RED** + +Run: + +```bash +pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx tests/image-pipeline/source-loader.test.ts tests/lib/canvas-mixer-preview.test.ts +``` + +Expected: failures showing current crop behavior still behaves like zoom/scale instead of edge trimming. + +**Step 3: Commit** + +```bash +git add components/canvas/__tests__/mixer-node.test.tsx tests/image-pipeline/source-loader.test.ts tests/lib/canvas-mixer-preview.test.ts +git commit -m "test(canvas): cover mixer resize and crop semantics" +``` + +--- + +### Task 2: Replace zoom-like content fields with crop-edge normalization + +**Files:** +- Modify: `lib/canvas-mixer-preview.ts` +- Modify: `lib/canvas-utils.ts` +- Modify: `lib/canvas-node-templates.ts` +- Modify: `components/canvas/nodes/mixer-node.tsx` + +**Step 1: Implement minimal normalized crop model** + +Prefer explicit crop trims: + +```ts +type MixerCropData = { + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}; +``` + +Normalization rules: + +- clamp each crop edge to `0..1` +- enforce minimum remaining source width/height +- preserve display frame fields separately +- map legacy `contentX/Y/Width/Height` into equivalent crop trims during normalization if needed + +**Step 2: Run focused tests** + +Run: + +```bash +pnpm exec vitest run tests/lib/canvas-mixer-preview.test.ts +``` + +Expected: GREEN for normalization and backward-compatibility cases. + +**Step 3: Commit** + +```bash +git add lib/canvas-mixer-preview.ts lib/canvas-utils.ts lib/canvas-node-templates.ts components/canvas/nodes/mixer-node.tsx tests/lib/canvas-mixer-preview.test.ts +git commit -m "feat(canvas): add explicit mixer crop edge model" +``` + +--- + +### Task 3: Fix mixer node interactions + +**Files:** +- Modify: `components/canvas/nodes/mixer-node.tsx` +- Modify: `components/canvas/__tests__/mixer-node.test.tsx` + +**Step 1: Implement proportional resize** + +- use display frame aspect ratio as the locked ratio +- corner drag scales frame proportionally +- side handles either hide in resize mode or preserve ratio while scaling +- resize mutates only `overlay*` + +**Step 2: Implement classic crop handles** + +- render 8 crop handles in crop mode +- edge handles trim one side +- corner handles trim two sides +- dragging inside crop box repositions crop region +- crop mutates only crop fields + +**Step 3: Run focused tests** + +Run: + +```bash +pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx +``` + +Expected: GREEN for resize and crop semantics. + +**Step 4: Commit** + +```bash +git add components/canvas/nodes/mixer-node.tsx components/canvas/__tests__/mixer-node.test.tsx +git commit -m "feat(canvas): separate mixer resize and crop interactions" +``` + +--- + +### Task 4: Align compare and bake semantics + +**Files:** +- Modify: `components/canvas/nodes/compare-surface.tsx` +- Modify: `lib/image-pipeline/source-loader.ts` +- Modify: `tests/image-pipeline/source-loader.test.ts` +- Modify: `tests/lib/canvas-render-preview.test.ts` +- Optional modify: `components/canvas/__tests__/compare-node.test.tsx` + +**Step 1: Implement crop-edge sampling everywhere** + +- compare preview uses crop edges, not zoom-like content scaling +- bake path samples cropped source region into overlay frame +- non-mixer behavior stays unchanged + +**Step 2: Run focused tests** + +Run: + +```bash +pnpm exec vitest run tests/image-pipeline/source-loader.test.ts tests/lib/canvas-render-preview.test.ts components/canvas/__tests__/compare-node.test.tsx +``` + +Expected: GREEN with preview/bake parity. + +**Step 3: Commit** + +```bash +git add components/canvas/nodes/compare-surface.tsx lib/image-pipeline/source-loader.ts tests/image-pipeline/source-loader.test.ts tests/lib/canvas-render-preview.test.ts components/canvas/__tests__/compare-node.test.tsx +git commit -m "fix(canvas): align mixer crop semantics across preview and bake" +``` + +--- + +### Task 5: Final verification + +**Files:** +- Modify only if docs or small follow-up fixes are needed + +**Step 1: Run the verification suite** + +```bash +pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx tests/lib/canvas-mixer-preview.test.ts tests/lib/canvas-render-preview.test.ts tests/image-pipeline/source-loader.test.ts components/canvas/__tests__/compare-node.test.tsx +pnpm lint +pnpm build +``` + +Expected: all green. + +**Step 2: Commit docs/follow-ups if needed** + +```bash +git add components/canvas/CLAUDE.md convex/CLAUDE.md +git commit -m "docs(canvas): document mixer resize and crop semantics" +``` + +--- + +## Notes + +- Keep `nodrag` and `nopan` on every interactive surface and handle. +- Prefer the smallest migration path from legacy `content*` fields into crop trims. +- Do not broaden into rotation, masks, or non-uniform scaling.