feat(canvas): add mixer drag-resize and mixer->render bake
This commit is contained in:
@@ -133,16 +133,22 @@ render: 300 × 420 mixer: 360 × 320
|
||||
- **Handles:** genau zwei Inputs links (`base`, `overlay`) und ein Output rechts (`mixer-out`).
|
||||
- **Erlaubte Inputs:** `image`, `asset`, `ai-image`, `render`.
|
||||
- **Connection-Limits:** maximal 2 eingehende Kanten insgesamt, davon pro Handle genau 1.
|
||||
- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `offsetX`, `offsetY`.
|
||||
- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `overlayX`, `overlayY`, `overlayWidth`, `overlayHeight` (normierte 0..1-Rect-Werte).
|
||||
- **Output-Semantik:** pseudo-image (clientseitig aus Graph + Controls aufgeloest), kein persistiertes Asset, kein Storage-Write.
|
||||
- **UI/Interaction:** nur Inline-Formcontrols im Node; keine Drag-Manipulation im Preview, keine Rotation/Skalierung/Masks.
|
||||
- **UI/Interaction:** Overlay ist im Preview direkt per Drag verschiebbar und ueber Corner-Handles frei resizable; numerische Inline-Controls bleiben als Feineinstellung erhalten.
|
||||
|
||||
### Compare-Integration (V1)
|
||||
|
||||
- `compare` versteht `mixer`-Outputs ueber `lib/canvas-mixer-preview.ts`.
|
||||
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Offset).
|
||||
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Overlay-Rect).
|
||||
- Scope bleibt eng: keine pauschale pseudo-image-Unterstuetzung fuer alle Consumer in V1.
|
||||
|
||||
### Render-Bake-Pfad (V1)
|
||||
|
||||
- Offizieller Bake-Flow: `mixer -> render`.
|
||||
- `render` konsumiert die Mixer-Komposition (`sourceComposition.kind = "mixer"`) und nutzt sie fuer Preview + finalen Render/Upload.
|
||||
- `mixer -> adjustments -> render` ist bewusst verschoben (deferred) und aktuell nicht offizieller Scope.
|
||||
|
||||
---
|
||||
|
||||
## Node-Status-Modell
|
||||
@@ -314,7 +320,7 @@ useCanvasData (use-canvas-data.ts)
|
||||
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
|
||||
- **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt.
|
||||
- **Mixer-Connection-Policy:** `mixer` akzeptiert nur `image|asset|ai-image|render`; Ziel-Handles sind nur `base` und `overlay`, pro Handle maximal eine eingehende Kante, insgesamt maximal zwei.
|
||||
- **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Downstream-Nodes muessen den pseudo-image-Resolver nutzen (aktuell gezielt fuer `compare`).
|
||||
- **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Offizielle Consumer sind `compare` und der direkte Bake-Pfad `mixer -> render`; `mixer -> adjustments -> render` bleibt vorerst deferred.
|
||||
- **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen.
|
||||
- **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren.
|
||||
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.
|
||||
|
||||
@@ -256,8 +256,10 @@ describe("CompareNode render preview inputs", () => {
|
||||
overlayUrl: "https://cdn.example.com/overlay.png",
|
||||
blendMode: "multiply",
|
||||
opacity: 62,
|
||||
offsetX: 12,
|
||||
offsetY: -4,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +52,10 @@ function buildMixerNodeProps(overrides?: Partial<React.ComponentProps<typeof Mix
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 0.5,
|
||||
overlayHeight: 0.5,
|
||||
},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
@@ -76,7 +78,30 @@ describe("MixerNode", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
const readyNodes: TestNode[] = [
|
||||
{ id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } },
|
||||
{ id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay.png" } },
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 0.5,
|
||||
overlayHeight: 0.5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const readyEdges: TestEdge[] = [
|
||||
{ id: "edge-base", source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{ id: "edge-overlay", source: "image-overlay", target: "mixer-1", targetHandle: "overlay" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.queueNodeDataUpdate.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -90,6 +115,7 @@ describe("MixerNode", () => {
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
vi.useRealTimers();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
@@ -130,26 +156,7 @@ describe("MixerNode", () => {
|
||||
});
|
||||
|
||||
it("renders ready state with stacked base and overlay previews", async () => {
|
||||
await renderNode({
|
||||
nodes: [
|
||||
{ id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } },
|
||||
{ id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay.png" } },
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: { blendMode: "multiply", opacity: 60, offsetX: 14, offsetY: -8 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "edge-base", source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{
|
||||
id: "edge-overlay",
|
||||
source: "image-overlay",
|
||||
target: "mixer-1",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
],
|
||||
});
|
||||
await renderNode({ nodes: readyNodes, edges: readyEdges });
|
||||
|
||||
const baseImage = container?.querySelector('img[alt="Mixer base"]');
|
||||
const overlayImage = container?.querySelector('img[alt="Mixer overlay"]');
|
||||
@@ -158,13 +165,199 @@ describe("MixerNode", () => {
|
||||
expect(overlayImage).toBeTruthy();
|
||||
});
|
||||
|
||||
it("queues node data updates for blend mode, opacity, and overlay offsets", async () => {
|
||||
it("drag updates persisted overlay geometry", async () => {
|
||||
await renderNode({ nodes: readyNodes, edges: readyEdges });
|
||||
|
||||
const preview = container?.querySelector('[data-testid="mixer-preview"]');
|
||||
const overlay = container?.querySelector('[data-testid="mixer-overlay"]');
|
||||
|
||||
if (!(preview instanceof HTMLDivElement)) {
|
||||
throw new Error("preview not found");
|
||||
}
|
||||
if (!(overlay instanceof HTMLImageElement)) {
|
||||
throw new Error("overlay image not found");
|
||||
}
|
||||
|
||||
vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
overlay.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 50, clientY: 50 }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 90, clientY: 70 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({
|
||||
overlayX: 0.2,
|
||||
overlayY: 0.1,
|
||||
overlayWidth: 0.5,
|
||||
overlayHeight: 0.5,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("drag clamps overlay bounds inside preview", async () => {
|
||||
await renderNode({ nodes: readyNodes, edges: readyEdges });
|
||||
|
||||
const preview = container?.querySelector('[data-testid="mixer-preview"]');
|
||||
const overlay = container?.querySelector('[data-testid="mixer-overlay"]');
|
||||
|
||||
if (!(preview instanceof HTMLDivElement)) {
|
||||
throw new Error("preview not found");
|
||||
}
|
||||
if (!(overlay instanceof HTMLImageElement)) {
|
||||
throw new Error("overlay image not found");
|
||||
}
|
||||
|
||||
vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
overlay.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 20, clientY: 20 }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 400, clientY: 380 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({
|
||||
overlayX: 0.5,
|
||||
overlayY: 0.5,
|
||||
overlayWidth: 0.5,
|
||||
overlayHeight: 0.5,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("resize updates persisted overlay width and height", async () => {
|
||||
await renderNode({ nodes: readyNodes, edges: readyEdges });
|
||||
|
||||
const preview = container?.querySelector('[data-testid="mixer-preview"]');
|
||||
const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]');
|
||||
|
||||
if (!(preview instanceof HTMLDivElement)) {
|
||||
throw new Error("preview not found");
|
||||
}
|
||||
if (!(resizeHandle instanceof HTMLDivElement)) {
|
||||
throw new Error("resize handle not found");
|
||||
}
|
||||
|
||||
vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resizeHandle.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 140, clientY: 120 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({
|
||||
overlayWidth: 0.7,
|
||||
overlayHeight: 0.6,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces minimum overlay size during resize", async () => {
|
||||
await renderNode({ nodes: readyNodes, edges: readyEdges });
|
||||
|
||||
const preview = container?.querySelector('[data-testid="mixer-preview"]');
|
||||
const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]');
|
||||
|
||||
if (!(preview instanceof HTMLDivElement)) {
|
||||
throw new Error("preview not found");
|
||||
}
|
||||
if (!(resizeHandle instanceof HTMLDivElement)) {
|
||||
throw new Error("resize handle not found");
|
||||
}
|
||||
|
||||
vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resizeHandle.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: -600, clientY: -700 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({
|
||||
overlayWidth: 0.1,
|
||||
overlayHeight: 0.1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("numeric controls still update overlay rect fields", async () => {
|
||||
await renderNode();
|
||||
|
||||
const blendMode = container?.querySelector('select[name="blendMode"]');
|
||||
const opacity = container?.querySelector('input[name="opacity"]');
|
||||
const offsetX = container?.querySelector('input[name="offsetX"]');
|
||||
const offsetY = container?.querySelector('input[name="offsetY"]');
|
||||
const overlayX = container?.querySelector('input[name="overlayX"]');
|
||||
const overlayY = container?.querySelector('input[name="overlayY"]');
|
||||
const overlayWidth = container?.querySelector('input[name="overlayWidth"]');
|
||||
const overlayHeight = container?.querySelector('input[name="overlayHeight"]');
|
||||
|
||||
if (!(blendMode instanceof HTMLSelectElement)) {
|
||||
throw new Error("blendMode select not found");
|
||||
@@ -172,16 +365,23 @@ describe("MixerNode", () => {
|
||||
if (!(opacity instanceof HTMLInputElement)) {
|
||||
throw new Error("opacity input not found");
|
||||
}
|
||||
if (!(offsetX instanceof HTMLInputElement)) {
|
||||
throw new Error("offsetX input not found");
|
||||
if (!(overlayX instanceof HTMLInputElement)) {
|
||||
throw new Error("overlayX input not found");
|
||||
}
|
||||
if (!(offsetY instanceof HTMLInputElement)) {
|
||||
throw new Error("offsetY input not found");
|
||||
if (!(overlayY instanceof HTMLInputElement)) {
|
||||
throw new Error("overlayY input not found");
|
||||
}
|
||||
if (!(overlayWidth instanceof HTMLInputElement)) {
|
||||
throw new Error("overlayWidth input not found");
|
||||
}
|
||||
if (!(overlayHeight instanceof HTMLInputElement)) {
|
||||
throw new Error("overlayHeight input not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
blendMode.value = "screen";
|
||||
blendMode.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
@@ -192,6 +392,7 @@ describe("MixerNode", () => {
|
||||
opacity.value = "45";
|
||||
opacity.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
opacity.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
@@ -199,23 +400,47 @@ describe("MixerNode", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
offsetX.value = "12";
|
||||
offsetX.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
offsetX.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
overlayX.value = "0.25";
|
||||
overlayX.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
overlayX.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ offsetX: 12 }),
|
||||
data: expect.objectContaining({ overlayX: 0.25 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
offsetY.value = "-6";
|
||||
offsetY.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
offsetY.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
overlayY.value = "0.4";
|
||||
overlayY.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
overlayY.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ offsetY: -6 }),
|
||||
data: expect.objectContaining({ overlayY: 0.4 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
overlayWidth.value = "0.66";
|
||||
overlayWidth.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
overlayWidth.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ overlayWidth: 0.66 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
overlayHeight.value = "0.33";
|
||||
overlayHeight.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
overlayHeight.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ overlayHeight: 0.33 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -155,8 +155,10 @@ describe("useCanvasConnections", () => {
|
||||
defaultData: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -169,8 +171,10 @@ describe("useCanvasConnections", () => {
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function CompareSurface({
|
||||
const graph = useCanvasGraph();
|
||||
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
|
||||
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
|
||||
const previewSourceComposition = usePreview ? previewInput?.sourceComposition : undefined;
|
||||
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
|
||||
const visibleFinalUrl = usePreview ? undefined : finalUrl;
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(
|
||||
@@ -43,6 +44,7 @@ export default function CompareSurface({
|
||||
|
||||
const { canvasRef, isRendering, error } = usePipelinePreview({
|
||||
sourceUrl: previewSourceUrl,
|
||||
sourceComposition: previewSourceComposition,
|
||||
steps: previewSteps,
|
||||
nodeWidth,
|
||||
includeHistogram: false,
|
||||
@@ -92,12 +94,15 @@ export default function CompareSurface({
|
||||
<img
|
||||
src={mixerPreviewState.overlayUrl}
|
||||
alt={label ?? "Comparison image"}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
className="absolute object-contain"
|
||||
draggable={false}
|
||||
style={{
|
||||
mixBlendMode: mixerPreviewState.blendMode,
|
||||
opacity: mixerPreviewState.opacity / 100,
|
||||
transform: `translate(${mixerPreviewState.offsetX}px, ${mixerPreviewState.offsetY}px)`,
|
||||
left: `${mixerPreviewState.overlayX * 100}%`,
|
||||
top: `${mixerPreviewState.overlayY * 100}%`,
|
||||
width: `${mixerPreviewState.overlayWidth * 100}%`,
|
||||
height: `${mixerPreviewState.overlayHeight * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { useNodeLocalData } from "./use-node-local-data";
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import {
|
||||
@@ -14,46 +23,267 @@ import {
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
|
||||
const MIN_OVERLAY_SIZE = 0.1;
|
||||
const MAX_OVERLAY_POSITION = 1;
|
||||
const SAVE_DELAY_MS = 160;
|
||||
|
||||
type MixerLocalData = ReturnType<typeof normalizeMixerPreviewData>;
|
||||
type ResizeCorner = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
type InteractionState =
|
||||
| {
|
||||
kind: "move";
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startData: MixerLocalData;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
}
|
||||
| {
|
||||
kind: "resize";
|
||||
corner: ResizeCorner;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startData: MixerLocalData;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeLocalMixerData(data: MixerLocalData): MixerLocalData {
|
||||
const overlayX = clamp(data.overlayX, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE);
|
||||
const overlayY = clamp(data.overlayY, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE);
|
||||
const overlayWidth = clamp(data.overlayWidth, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayX);
|
||||
const overlayHeight = clamp(data.overlayHeight, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayY);
|
||||
|
||||
return {
|
||||
...data,
|
||||
overlayX,
|
||||
overlayY,
|
||||
overlayWidth,
|
||||
overlayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function computeResizeRect(args: {
|
||||
startData: MixerLocalData;
|
||||
corner: ResizeCorner;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
}): Pick<MixerLocalData, "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight"> {
|
||||
const { startData, corner, deltaX, deltaY } = args;
|
||||
const startRight = startData.overlayX + startData.overlayWidth;
|
||||
const startBottom = startData.overlayY + startData.overlayHeight;
|
||||
|
||||
let overlayX = startData.overlayX;
|
||||
let overlayY = startData.overlayY;
|
||||
let overlayWidth = startData.overlayWidth;
|
||||
let overlayHeight = startData.overlayHeight;
|
||||
|
||||
if (corner.includes("w")) {
|
||||
overlayX = clamp(
|
||||
startData.overlayX + deltaX,
|
||||
0,
|
||||
startData.overlayX + startData.overlayWidth - MIN_OVERLAY_SIZE,
|
||||
);
|
||||
overlayWidth = startRight - overlayX;
|
||||
}
|
||||
|
||||
if (corner.includes("e")) {
|
||||
overlayWidth = clamp(
|
||||
startData.overlayWidth + deltaX,
|
||||
MIN_OVERLAY_SIZE,
|
||||
MAX_OVERLAY_POSITION - startData.overlayX,
|
||||
);
|
||||
}
|
||||
|
||||
if (corner.includes("n")) {
|
||||
overlayY = clamp(
|
||||
startData.overlayY + deltaY,
|
||||
0,
|
||||
startData.overlayY + startData.overlayHeight - MIN_OVERLAY_SIZE,
|
||||
);
|
||||
overlayHeight = startBottom - overlayY;
|
||||
}
|
||||
|
||||
if (corner.includes("s")) {
|
||||
overlayHeight = clamp(
|
||||
startData.overlayHeight + deltaY,
|
||||
MIN_OVERLAY_SIZE,
|
||||
MAX_OVERLAY_POSITION - startData.overlayY,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeLocalMixerData({
|
||||
...startData,
|
||||
overlayX,
|
||||
overlayY,
|
||||
overlayWidth,
|
||||
overlayHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
const graph = useCanvasGraph();
|
||||
const { queueNodeDataUpdate } = useCanvasSync();
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestNodeDataRef = useRef((data ?? {}) as Record<string, unknown>);
|
||||
const [hasImageLoadError, setHasImageLoadError] = useState(false);
|
||||
const [interaction, setInteraction] = useState<InteractionState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestNodeDataRef.current = (data ?? {}) as Record<string, unknown>;
|
||||
}, [data]);
|
||||
|
||||
const { localData, updateLocalData } = useNodeLocalData<MixerLocalData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeMixerPreviewData,
|
||||
saveDelayMs: SAVE_DELAY_MS,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...latestNodeDataRef.current,
|
||||
...next,
|
||||
},
|
||||
}),
|
||||
debugLabel: "mixer",
|
||||
});
|
||||
|
||||
const normalizedData = useMemo(() => normalizeMixerPreviewData(data), [data]);
|
||||
const previewState = useMemo(
|
||||
() => resolveMixerPreviewFromGraph({ nodeId: id, graph }),
|
||||
[graph, id],
|
||||
);
|
||||
|
||||
const currentData = (data ?? {}) as Record<string, unknown>;
|
||||
|
||||
const updateData = (patch: Partial<ReturnType<typeof normalizeMixerPreviewData>>) => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...currentData,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onBlendModeChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setHasImageLoadError(false);
|
||||
updateData({ blendMode: event.target.value as MixerBlendMode });
|
||||
updateLocalData((current) => ({
|
||||
...current,
|
||||
blendMode: event.target.value as MixerBlendMode,
|
||||
}));
|
||||
};
|
||||
|
||||
const onNumberChange = (field: "opacity" | "offsetX" | "offsetY") => (
|
||||
event: FormEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const onNumberChange = (
|
||||
field: "opacity" | "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight",
|
||||
) =>
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
setHasImageLoadError(false);
|
||||
const nextValue = Number(event.currentTarget.value);
|
||||
updateData({ [field]: Number.isFinite(nextValue) ? nextValue : 0 });
|
||||
|
||||
updateLocalData((current) => {
|
||||
if (!Number.isFinite(nextValue)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (field === "opacity") {
|
||||
return {
|
||||
...current,
|
||||
opacity: clamp(nextValue, 0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeLocalMixerData({
|
||||
...current,
|
||||
[field]: nextValue,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startInteraction = (
|
||||
event: ReactMouseEvent<HTMLElement>,
|
||||
kind: InteractionState["kind"],
|
||||
corner?: ResizeCorner,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const previewRect = previewRef.current?.getBoundingClientRect();
|
||||
if (!previewRect || previewRect.width <= 0 || previewRect.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInteraction({
|
||||
kind,
|
||||
corner: kind === "resize" ? (corner as ResizeCorner) : undefined,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startData: localData,
|
||||
previewWidth: previewRect.width,
|
||||
previewHeight: previewRect.height,
|
||||
} as InteractionState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const deltaX = (event.clientX - interaction.startClientX) / interaction.previewWidth;
|
||||
const deltaY = (event.clientY - interaction.startClientY) / interaction.previewHeight;
|
||||
|
||||
if (interaction.kind === "move") {
|
||||
const nextX = clamp(
|
||||
interaction.startData.overlayX + deltaX,
|
||||
0,
|
||||
MAX_OVERLAY_POSITION - interaction.startData.overlayWidth,
|
||||
);
|
||||
const nextY = clamp(
|
||||
interaction.startData.overlayY + deltaY,
|
||||
0,
|
||||
MAX_OVERLAY_POSITION - interaction.startData.overlayHeight,
|
||||
);
|
||||
|
||||
updateLocalData((current) => ({
|
||||
...current,
|
||||
overlayX: nextX,
|
||||
overlayY: nextY,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRect = computeResizeRect({
|
||||
startData: interaction.startData,
|
||||
corner: interaction.corner,
|
||||
deltaX,
|
||||
deltaY,
|
||||
});
|
||||
|
||||
updateLocalData((current) => ({
|
||||
...current,
|
||||
...nextRect,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setInteraction(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [interaction, updateLocalData]);
|
||||
|
||||
const showReadyPreview = previewState.status === "ready" && !hasImageLoadError;
|
||||
const showPreviewError = hasImageLoadError || previewState.status === "error";
|
||||
|
||||
const overlayStyle = {
|
||||
mixBlendMode: localData.blendMode,
|
||||
opacity: localData.opacity / 100,
|
||||
left: `${localData.overlayX * 100}%`,
|
||||
top: `${localData.overlayY * 100}%`,
|
||||
width: `${localData.overlayWidth * 100}%`,
|
||||
height: `${localData.overlayHeight * 100}%`,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
|
||||
<Handle
|
||||
@@ -82,7 +312,7 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
Mixer
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[140px] overflow-hidden bg-muted/40">
|
||||
<div ref={previewRef} data-testid="mixer-preview" className="relative min-h-[140px] overflow-hidden bg-muted/40 nodrag">
|
||||
{showReadyPreview ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -97,15 +327,35 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
<img
|
||||
src={previewState.overlayUrl}
|
||||
alt="Mixer overlay"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
data-testid="mixer-overlay"
|
||||
className="absolute object-cover nodrag cursor-move"
|
||||
draggable={false}
|
||||
onMouseDown={(event) => startInteraction(event, "move")}
|
||||
onError={() => setHasImageLoadError(true)}
|
||||
style={{
|
||||
mixBlendMode: previewState.blendMode,
|
||||
opacity: previewState.opacity / 100,
|
||||
transform: `translate(${previewState.offsetX}px, ${previewState.offsetY}px)`,
|
||||
}}
|
||||
style={overlayStyle}
|
||||
/>
|
||||
|
||||
{([
|
||||
{ corner: "nw", cursor: "nwse-resize" },
|
||||
{ corner: "ne", cursor: "nesw-resize" },
|
||||
{ corner: "sw", cursor: "nesw-resize" },
|
||||
{ corner: "se", cursor: "nwse-resize" },
|
||||
] as const).map(({ corner, cursor }) => (
|
||||
<div
|
||||
key={corner}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
data-testid={`mixer-resize-${corner}`}
|
||||
className="absolute z-10 h-3 w-3 rounded-full border border-white/80 bg-foreground/80 nodrag"
|
||||
onMouseDown={(event) => startInteraction(event, "resize", corner)}
|
||||
style={{
|
||||
left: `${(corner.includes("w") ? localData.overlayX : localData.overlayX + localData.overlayWidth) * 100}%`,
|
||||
top: `${(corner.includes("n") ? localData.overlayY : localData.overlayY + localData.overlayHeight) * 100}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
cursor,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -133,7 +383,7 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
<span>Blend mode</span>
|
||||
<select
|
||||
name="blendMode"
|
||||
value={normalizedData.blendMode}
|
||||
value={localData.blendMode}
|
||||
onChange={onBlendModeChange}
|
||||
className="nodrag h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
>
|
||||
@@ -154,32 +404,64 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={normalizedData.opacity}
|
||||
value={localData.opacity}
|
||||
onInput={onNumberChange("opacity")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Offset X</span>
|
||||
<span>Overlay X</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="offsetX"
|
||||
step={1}
|
||||
value={normalizedData.offsetX}
|
||||
onInput={onNumberChange("offsetX")}
|
||||
name="overlayX"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={localData.overlayX}
|
||||
onInput={onNumberChange("overlayX")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="col-span-2 flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Offset Y</span>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Overlay Y</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="offsetY"
|
||||
step={1}
|
||||
value={normalizedData.offsetY}
|
||||
onInput={onNumberChange("offsetY")}
|
||||
name="overlayY"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={localData.overlayY}
|
||||
onInput={onNumberChange("overlayY")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Overlay W</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="overlayWidth"
|
||||
min={MIN_OVERLAY_SIZE}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.overlayWidth}
|
||||
onInput={onNumberChange("overlayWidth")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Overlay H</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="overlayHeight"
|
||||
min={MIN_OVERLAY_SIZE}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.overlayHeight}
|
||||
onInput={onNumberChange("overlayHeight")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -463,11 +463,13 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
);
|
||||
|
||||
const sourceUrl = renderPreviewInput.sourceUrl;
|
||||
const sourceComposition = renderPreviewInput.sourceComposition;
|
||||
|
||||
useEffect(() => {
|
||||
logRenderDebug("node-data-updated", {
|
||||
nodeId: id,
|
||||
hasSourceUrl: typeof sourceUrl === "string" && sourceUrl.length > 0,
|
||||
hasSourceComposition: Boolean(sourceComposition),
|
||||
storageId: data.storageId ?? null,
|
||||
lastUploadStorageId: data.lastUploadStorageId ?? null,
|
||||
hasResolvedUrl: typeof data.url === "string" && data.url.length > 0,
|
||||
@@ -484,6 +486,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
data.url,
|
||||
id,
|
||||
sourceUrl,
|
||||
sourceComposition,
|
||||
]);
|
||||
|
||||
const sourceNode = useMemo<SourceNodeDescriptor | null>(
|
||||
@@ -525,9 +528,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
);
|
||||
|
||||
const currentPipelineHash = useMemo(() => {
|
||||
if (!sourceUrl) return null;
|
||||
return hashPipeline({ sourceUrl, render: renderFingerprint }, steps);
|
||||
}, [renderFingerprint, sourceUrl, steps]);
|
||||
if (!sourceUrl && !sourceComposition) return null;
|
||||
return hashPipeline(
|
||||
{ source: sourceComposition ?? sourceUrl, render: renderFingerprint },
|
||||
steps,
|
||||
);
|
||||
}, [renderFingerprint, sourceComposition, sourceUrl, steps]);
|
||||
|
||||
const isRenderCurrent =
|
||||
Boolean(currentPipelineHash) && localData.lastRenderedHash === currentPipelineHash;
|
||||
@@ -557,7 +563,8 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
const hasSource = typeof sourceUrl === "string" && sourceUrl.length > 0;
|
||||
const hasSource =
|
||||
(typeof sourceUrl === "string" && sourceUrl.length > 0) || Boolean(sourceComposition);
|
||||
const previewNodeWidth = Math.max(260, Math.round(width ?? 320));
|
||||
|
||||
const {
|
||||
@@ -568,6 +575,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
error: previewError,
|
||||
} = usePipelinePreview({
|
||||
sourceUrl,
|
||||
sourceComposition,
|
||||
steps,
|
||||
nodeWidth: previewNodeWidth,
|
||||
debounceMs: previewDebounceMs,
|
||||
@@ -585,6 +593,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
error: fullscreenPreviewError,
|
||||
} = usePipelinePreview({
|
||||
sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null,
|
||||
sourceComposition: isFullscreenOpen ? sourceComposition : undefined,
|
||||
steps,
|
||||
nodeWidth: fullscreenPreviewWidth,
|
||||
includeHistogram: false,
|
||||
@@ -719,11 +728,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
};
|
||||
|
||||
const handleRender = async (mode: "download" | "upload") => {
|
||||
if (!sourceUrl || !currentPipelineHash) {
|
||||
if ((!sourceUrl && !sourceComposition) || !currentPipelineHash) {
|
||||
logRenderDebug("render-aborted-prerequisites", {
|
||||
nodeId: id,
|
||||
mode,
|
||||
hasSourceUrl: Boolean(sourceUrl),
|
||||
hasSourceComposition: Boolean(sourceComposition),
|
||||
hasPipelineHash: Boolean(currentPipelineHash),
|
||||
isOffline: status.isOffline,
|
||||
});
|
||||
@@ -768,7 +778,8 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
});
|
||||
|
||||
const renderResult = await renderFullWithWorkerFallback({
|
||||
sourceUrl,
|
||||
sourceUrl: sourceUrl ?? undefined,
|
||||
sourceComposition,
|
||||
steps,
|
||||
render: {
|
||||
resolution: activeData.outputResolution,
|
||||
|
||||
Reference in New Issue
Block a user