feat(canvas): add mixer drag-resize and mixer->render bake

This commit is contained in:
2026-04-11 10:03:41 +02:00
parent ae2fa1d269
commit f499aea691
28 changed files with 1731 additions and 152 deletions

View File

@@ -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}%`,
}}
/>
</>

View File

@@ -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>

View File

@@ -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,