feat(canvas): separate mixer resize and crop semantics
This commit is contained in:
@@ -4,7 +4,7 @@ import { buildGraphSnapshot } from "@/lib/canvas-render-preview";
|
||||
import { resolveMixerPreviewFromGraph } from "@/lib/canvas-mixer-preview";
|
||||
|
||||
describe("resolveMixerPreviewFromGraph", () => {
|
||||
it("resolves base and overlay URLs by target handle", () => {
|
||||
it("resolves base and overlay URLs by target handle while keeping frame and crop trims independent", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
@@ -32,6 +32,10 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0.2,
|
||||
overlayWidth: 0.6,
|
||||
overlayHeight: 0.5,
|
||||
cropLeft: 0.08,
|
||||
cropTop: 0.15,
|
||||
cropRight: 0.22,
|
||||
cropBottom: 0.1,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -52,10 +56,110 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0.2,
|
||||
overlayWidth: 0.6,
|
||||
overlayHeight: 0.5,
|
||||
cropLeft: 0.08,
|
||||
cropTop: 0.15,
|
||||
cropRight: 0.22,
|
||||
cropBottom: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers render output URL over upstream preview source when available", () => {
|
||||
it("preserves crop trims when frame resize data changes", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-asset",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
overlayX: 0.2,
|
||||
overlayY: 0.1,
|
||||
overlayWidth: 0.6,
|
||||
overlayHeight: 0.3,
|
||||
cropLeft: 0.15,
|
||||
cropTop: 0.05,
|
||||
cropRight: 0.4,
|
||||
cropBottom: 0.25,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual(
|
||||
expect.objectContaining({
|
||||
overlayX: 0.2,
|
||||
overlayY: 0.1,
|
||||
overlayWidth: 0.6,
|
||||
overlayHeight: 0.3,
|
||||
cropLeft: 0.15,
|
||||
cropTop: 0.05,
|
||||
cropRight: 0.4,
|
||||
cropBottom: 0.25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves overlayWidth and overlayHeight when crop trims change", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-asset",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
overlayX: 0.05,
|
||||
overlayY: 0.25,
|
||||
overlayWidth: 0.55,
|
||||
overlayHeight: 0.35,
|
||||
cropLeft: 0.4,
|
||||
cropTop: 0.1,
|
||||
cropRight: 0.3,
|
||||
cropBottom: 0.1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual(
|
||||
expect.objectContaining({
|
||||
overlayX: 0.05,
|
||||
overlayY: 0.25,
|
||||
overlayWidth: 0.55,
|
||||
overlayHeight: 0.35,
|
||||
cropLeft: 0.4,
|
||||
cropTop: 0.1,
|
||||
cropRight: 0.3,
|
||||
cropBottom: 0.1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers live render preview URL over stale baked render output", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
@@ -91,13 +195,79 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/render-output.png",
|
||||
overlayUrl: "https://cdn.example.com/upstream.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse stale baked render output when only live sourceComposition exists", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "base-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/overlay-base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-asset",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay-asset.png" },
|
||||
},
|
||||
{
|
||||
id: "upstream-mixer",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: "render-overlay",
|
||||
type: "render",
|
||||
data: {
|
||||
lastUploadUrl: "https://cdn.example.com/stale-render-output.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "overlay-base", target: "upstream-mixer", targetHandle: "base" },
|
||||
{ source: "overlay-asset", target: "upstream-mixer", targetHandle: "overlay" },
|
||||
{ source: "upstream-mixer", target: "render-overlay" },
|
||||
{ source: "base-image", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "render-overlay", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "partial",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: undefined,
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,10 +298,14 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes rect values and clamps", () => {
|
||||
it("normalizes crop trims and clamps", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
@@ -154,6 +328,10 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: "1.4",
|
||||
overlayWidth: 2,
|
||||
overlayHeight: 0,
|
||||
cropLeft: "0.95",
|
||||
cropTop: -2,
|
||||
cropRight: "4",
|
||||
cropBottom: "0",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -173,6 +351,10 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0.9,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 0.1,
|
||||
cropLeft: 0.9,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +396,57 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps legacy content rect fields into crop trims during normalization", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "base-ai",
|
||||
type: "ai-image",
|
||||
data: { url: "https://cdn.example.com/base-ai.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-asset",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay-asset.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
contentX: 0.2,
|
||||
contentY: 0.1,
|
||||
contentWidth: 0.5,
|
||||
contentHeight: 0.6,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "base-ai", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base-ai.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay-asset.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0.2,
|
||||
cropTop: 0.1,
|
||||
cropRight: 0.30000000000000004,
|
||||
cropBottom: 0.30000000000000004,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,6 +488,10 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +530,10 @@ describe("resolveMixerPreviewFromGraph", () => {
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
error: "duplicate-handle-edge",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
buildGraphSnapshot,
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
computeMixerCompareOverlayImageStyle,
|
||||
computeMixerFrameRectInSurface,
|
||||
computeVisibleMixerContentRect,
|
||||
computeMixerCropImageStyle,
|
||||
isMixerCropImageReady,
|
||||
} from "@/lib/mixer-crop-layout";
|
||||
|
||||
describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
it("resolves mixer input as renderable mixer composition", () => {
|
||||
@@ -29,6 +36,10 @@ describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
overlayY: 0.1,
|
||||
overlayWidth: 0.55,
|
||||
overlayHeight: 0.44,
|
||||
cropLeft: 0.08,
|
||||
cropTop: 0.15,
|
||||
cropRight: 0.22,
|
||||
cropBottom: 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -61,6 +72,10 @@ describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
overlayY: 0.1,
|
||||
overlayWidth: 0.55,
|
||||
overlayHeight: 0.44,
|
||||
cropLeft: 0.08,
|
||||
cropTop: 0.15,
|
||||
cropRight: 0.22,
|
||||
cropBottom: 0.1,
|
||||
},
|
||||
steps: [],
|
||||
});
|
||||
@@ -89,6 +104,10 @@ describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
overlayY: "1.4",
|
||||
overlayWidth: 2,
|
||||
overlayHeight: 0,
|
||||
cropLeft: "0.95",
|
||||
cropTop: -2,
|
||||
cropRight: "4",
|
||||
cropBottom: "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -119,6 +138,10 @@ describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
overlayY: 0.9,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 0.1,
|
||||
cropLeft: 0.9,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,4 +229,189 @@ describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
expect(preview.sourceUrl).toBe("https://cdn.example.com/generated-video.mp4");
|
||||
expect(preview.sourceComposition).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers live render preview URLs over stale baked render URLs inside downstream mixer compositions", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "base-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-upstream",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/upstream.png" },
|
||||
},
|
||||
{
|
||||
id: "render-overlay",
|
||||
type: "render",
|
||||
data: {
|
||||
lastUploadUrl: "https://cdn.example.com/stale-render-output.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: "render-2",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "overlay-upstream", target: "render-overlay" },
|
||||
{ source: "base-image", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "render-overlay", target: "mixer-1", targetHandle: "overlay" },
|
||||
{ source: "mixer-1", target: "render-2" },
|
||||
],
|
||||
);
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({ nodeId: "render-2", graph });
|
||||
|
||||
expect(preview).toEqual({
|
||||
sourceUrl: null,
|
||||
sourceComposition: {
|
||||
kind: "mixer",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/upstream.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
},
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixer crop layout parity", () => {
|
||||
it("contains a wide cropped source inside a square overlay frame", () => {
|
||||
expect(
|
||||
computeVisibleMixerContentRect({
|
||||
frameAspectRatio: 1,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 100,
|
||||
cropLeft: 0,
|
||||
cropTop: 0.25,
|
||||
cropRight: 0,
|
||||
cropBottom: 0.25,
|
||||
}),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: 0.375,
|
||||
width: 1,
|
||||
height: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns compare image styles that letterbox instead of stretching", () => {
|
||||
expect(
|
||||
computeMixerCropImageStyle({
|
||||
frameAspectRatio: 1,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 100,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
left: "0%",
|
||||
top: "25%",
|
||||
width: "100%",
|
||||
height: "50%",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the actual base-aware frame pixel ratio for compare crop math", () => {
|
||||
expect(
|
||||
computeMixerCompareOverlayImageStyle({
|
||||
surfaceWidth: 500,
|
||||
surfaceHeight: 380,
|
||||
baseWidth: 200,
|
||||
baseHeight: 100,
|
||||
overlayX: 0.1,
|
||||
overlayY: 0.2,
|
||||
overlayWidth: 0.4,
|
||||
overlayHeight: 0.4,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 100,
|
||||
cropLeft: 0.1,
|
||||
cropTop: 0,
|
||||
cropRight: 0.1,
|
||||
cropBottom: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
left: "0%",
|
||||
top: "0%",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark compare crop overlay ready before natural size is known", () => {
|
||||
expect(
|
||||
isMixerCropImageReady({
|
||||
currentOverlayUrl: "https://cdn.example.com/overlay-a.png",
|
||||
loadedOverlayUrl: null,
|
||||
sourceWidth: 0,
|
||||
sourceHeight: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates compare crop overlay readiness on source swap until the new image loads", () => {
|
||||
expect(
|
||||
isMixerCropImageReady({
|
||||
currentOverlayUrl: "https://cdn.example.com/overlay-b.png",
|
||||
loadedOverlayUrl: "https://cdn.example.com/overlay-a.png",
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 100,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("positions mixer overlay frame relative to the displayed base-image rect", () => {
|
||||
expect(
|
||||
computeMixerFrameRectInSurface({
|
||||
surfaceWidth: 1,
|
||||
surfaceHeight: 1,
|
||||
baseWidth: 200,
|
||||
baseHeight: 100,
|
||||
overlayX: 0.1,
|
||||
overlayY: 0.2,
|
||||
overlayWidth: 0.4,
|
||||
overlayHeight: 0.4,
|
||||
}),
|
||||
).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.35,
|
||||
width: 0.4,
|
||||
height: 0.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null frame placement until base image natural size is known", () => {
|
||||
expect(
|
||||
computeMixerFrameRectInSurface({
|
||||
surfaceWidth: 1,
|
||||
surfaceHeight: 1,
|
||||
baseWidth: 0,
|
||||
baseHeight: 0,
|
||||
overlayX: 0.1,
|
||||
overlayY: 0.2,
|
||||
overlayWidth: 0.4,
|
||||
overlayHeight: 0.4,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user