Merge branch 'feat/mixer-overlay-resize-render-bake'

This commit is contained in:
2026-04-15 08:46:42 +02:00
30 changed files with 6361 additions and 194 deletions

View File

@@ -1,5 +1,9 @@
// @vitest-environment jsdom
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
@@ -15,12 +19,20 @@ type StoreState = {
}>;
};
type ResizeObserverEntryLike = {
target: Element;
contentRect: { width: number; height: number };
};
const storeState: StoreState = {
nodes: [],
edges: [],
};
const compareSurfaceSpy = vi.fn();
let resizeObserverCallback:
| ((entries: ResizeObserverEntryLike[]) => void)
| null = null;
vi.mock("@xyflow/react", () => ({
Handle: () => null,
@@ -53,6 +65,14 @@ vi.mock("@/components/canvas/canvas-handle", () => ({
),
}));
vi.mock("@/hooks/use-pipeline-preview", () => ({
usePipelinePreview: () => ({
canvasRef: { current: null },
isRendering: false,
error: null,
}),
}));
vi.mock("../nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
@@ -66,6 +86,8 @@ vi.mock("../nodes/compare-surface", () => ({
import CompareNode from "../nodes/compare-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function renderCompareNode(props: Record<string, unknown>) {
return renderToStaticMarkup(
<CanvasGraphProvider
@@ -78,10 +100,47 @@ function renderCompareNode(props: Record<string, unknown>) {
}
describe("CompareNode render preview inputs", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
storeState.nodes = [];
storeState.edges = [];
compareSurfaceSpy.mockReset();
resizeObserverCallback = null;
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback: (entries: ResizeObserverEntryLike[]) => void) {
resizeObserverCallback = callback;
}
observe(target: Element) {
resizeObserverCallback?.([
{
target,
contentRect: { width: 500, height: 380 },
},
]);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("passes previewInput to CompareSurface for a connected render node without final output", () => {
@@ -192,6 +251,108 @@ describe("CompareNode render preview inputs", () => {
});
});
it("defaults mixer-backed render compare inputs to preview mode when only sourceComposition exists", () => {
storeState.nodes = [
{
id: "base-image",
type: "image",
data: { url: "https://cdn.example.com/base.png" },
},
{
id: "overlay-image",
type: "asset",
data: { url: "https://cdn.example.com/overlay.png" },
},
{
id: "mixer-1",
type: "mixer",
data: {
blendMode: "multiply",
opacity: 62,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.4,
overlayHeight: 0.5,
cropLeft: 0.1,
cropTop: 0,
cropRight: 0.2,
cropBottom: 0.1,
},
},
{
id: "render-1",
type: "render",
data: {
lastUploadUrl: "https://cdn.example.com/stale-render-output.png",
},
},
];
storeState.edges = [
{
id: "edge-base-mixer",
source: "base-image",
target: "mixer-1",
targetHandle: "base",
},
{
id: "edge-overlay-mixer",
source: "overlay-image",
target: "mixer-1",
targetHandle: "overlay",
},
{ id: "edge-mixer-render", source: "mixer-1", target: "render-1" },
{
id: "edge-render-compare",
source: "render-1",
target: "compare-1",
targetHandle: "left",
},
];
renderCompareNode({
id: "compare-1",
data: { leftUrl: "https://cdn.example.com/stale-render-output.png" },
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "compare",
xPos: 0,
yPos: 0,
width: 500,
height: 380,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
});
expect(compareSurfaceSpy).toHaveBeenCalledTimes(1);
expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({
finalUrl: "https://cdn.example.com/stale-render-output.png",
preferPreview: true,
previewInput: {
sourceUrl: null,
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "multiply",
opacity: 62,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.4,
overlayHeight: 0.5,
cropLeft: 0.1,
cropTop: 0,
cropRight: 0.2,
cropBottom: 0.1,
},
steps: [],
},
});
});
it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => {
storeState.nodes = [
{
@@ -275,14 +436,22 @@ describe("CompareNode render preview inputs", () => {
);
expect(mixerCall?.[0]).toMatchObject({
finalUrl: undefined,
nodeWidth: 500,
nodeHeight: 380,
mixerPreviewState: {
status: "ready",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "multiply",
opacity: 62,
offsetX: 12,
offsetY: -4,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
},
});
});
@@ -317,4 +486,183 @@ describe("CompareNode render preview inputs", () => {
expect(markup).toContain('data-top="35%"');
expect(markup).toContain('data-top="55%"');
});
it("passes the measured compare surface size to mixer previews instead of the full node box", async () => {
storeState.nodes = [
{
id: "base-image",
type: "image",
data: { url: "https://cdn.example.com/base.png" },
},
{
id: "overlay-image",
type: "asset",
data: { url: "https://cdn.example.com/overlay.png" },
},
{
id: "mixer-1",
type: "mixer",
data: {
blendMode: "normal",
opacity: 100,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.6,
overlayHeight: 0.5,
},
},
];
storeState.edges = [
{
id: "edge-base-mixer",
source: "base-image",
target: "mixer-1",
targetHandle: "base",
},
{
id: "edge-overlay-mixer",
source: "overlay-image",
target: "mixer-1",
targetHandle: "overlay",
},
{
id: "edge-mixer-compare",
source: "mixer-1",
target: "compare-1",
targetHandle: "left",
},
];
await act(async () => {
root?.render(
<CanvasGraphProvider
nodes={storeState.nodes as Array<{ id: string; type: string; data?: unknown }>}
edges={storeState.edges}
>
<CompareNode
{...({
id: "compare-1",
data: {},
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "compare",
xPos: 0,
yPos: 0,
width: 640,
height: 480,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as unknown as React.ComponentProps<typeof CompareNode>)}
/>
</CanvasGraphProvider>,
);
});
await vi.waitFor(() => {
const latestCompareSurfaceCall = compareSurfaceSpy.mock.calls.findLast(
([props]) =>
Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState),
);
expect(latestCompareSurfaceCall?.[0]).toMatchObject({
nodeWidth: 500,
nodeHeight: 380,
});
});
const surfaceElement = container?.querySelector(".nodrag.relative.min-h-0.w-full");
expect(surfaceElement).toBeInstanceOf(HTMLDivElement);
await act(async () => {
resizeObserverCallback?.([
{
target: surfaceElement as HTMLDivElement,
contentRect: { width: 468, height: 312 },
},
]);
});
const latestCompareSurfaceCall = compareSurfaceSpy.mock.calls.findLast(
([props]) =>
Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState),
);
expect(latestCompareSurfaceCall?.[0]).toMatchObject({
nodeWidth: 468,
nodeHeight: 312,
});
expect(latestCompareSurfaceCall?.[0]).not.toMatchObject({
nodeWidth: 640,
nodeHeight: 480,
});
});
it("anchors direct mixer previews to the actual compare surface rect", async () => {
const compareSurfaceModule = await vi.importActual<typeof import("../nodes/compare-surface")>(
"../nodes/compare-surface",
);
const ActualCompareSurface = compareSurfaceModule.default;
await act(async () => {
root?.render(
<CanvasGraphProvider nodes={[]} edges={[]}>
<ActualCompareSurface
mixerPreviewState={{
status: "ready",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "normal",
opacity: 100,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
}}
nodeWidth={500}
nodeHeight={380}
/>
</CanvasGraphProvider>,
);
});
const images = container?.querySelectorAll("img");
const baseImage = images?.[0];
if (!(baseImage instanceof HTMLImageElement)) {
throw new Error("base image not found");
}
Object.defineProperty(baseImage, "naturalWidth", { configurable: true, value: 200 });
Object.defineProperty(baseImage, "naturalHeight", { configurable: true, value: 100 });
await act(async () => {
baseImage.dispatchEvent(new Event("load"));
});
const overlayImage = container?.querySelectorAll("img")?.[1];
if (!(overlayImage instanceof HTMLImageElement)) {
throw new Error("overlay image not found");
}
Object.defineProperty(overlayImage, "naturalWidth", { configurable: true, value: 200 });
Object.defineProperty(overlayImage, "naturalHeight", { configurable: true, value: 100 });
await act(async () => {
overlayImage.dispatchEvent(new Event("load"));
});
const overlayFrame = overlayImage.parentElement;
expect(overlayFrame?.style.left).toBe("0%");
expect(overlayFrame?.style.top).toBe("17.105263157894736%");
expect(overlayFrame?.style.width).toBe("100%");
expect(overlayFrame?.style.height).toBe("65.78947368421053%");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -218,8 +218,10 @@ describe("useCanvasConnections", () => {
defaultData: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
},
}),
);
@@ -232,8 +234,10 @@ describe("useCanvasConnections", () => {
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
},
}),
);