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

@@ -17,6 +17,13 @@ const sourceLoaderMocks = vi.hoisted(() => ({
vi.mock("@/lib/image-pipeline/source-loader", () => ({
loadSourceBitmap: sourceLoaderMocks.loadSourceBitmap,
loadRenderSourceBitmap: ({ sourceUrl }: { sourceUrl?: string }) => {
if (!sourceUrl) {
throw new Error("Render source is required.");
}
return sourceLoaderMocks.loadSourceBitmap(sourceUrl);
},
}));
function createPreviewPixels(): Uint8ClampedArray {

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RenderFullResult, RenderSourceComposition } from "@/lib/image-pipeline/render-types";
const bridgeMocks = vi.hoisted(() => ({
renderFull: vi.fn(),
}));
const previewRendererMocks = vi.hoisted(() => ({
renderPreview: vi.fn(),
}));
vi.mock("@/lib/image-pipeline/bridge", () => ({
renderFull: bridgeMocks.renderFull,
}));
vi.mock("@/lib/image-pipeline/preview-renderer", () => ({
renderPreview: previewRendererMocks.renderPreview,
}));
type WorkerMessage = {
kind: "full";
requestId: number;
payload: {
sourceUrl?: string;
sourceComposition?: RenderSourceComposition;
steps: [];
render: {
resolution: "original";
format: "png";
};
};
};
type WorkerScopeMock = {
postMessage: ReturnType<typeof vi.fn>;
onmessage: ((event: MessageEvent<WorkerMessage>) => void) | null;
};
function createFullResult(): RenderFullResult {
return {
blob: new Blob(["rendered"]),
width: 64,
height: 64,
mimeType: "image/png",
format: "png",
quality: null,
sizeBytes: 8,
sourceWidth: 64,
sourceHeight: 64,
wasSizeClamped: false,
};
}
function createWorkerScope(): WorkerScopeMock {
return {
postMessage: vi.fn(),
onmessage: null,
};
}
describe("image-pipeline.worker full render", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
bridgeMocks.renderFull.mockReset();
bridgeMocks.renderFull.mockResolvedValue(createFullResult());
previewRendererMocks.renderPreview.mockReset();
});
it("forwards sourceComposition to renderFull for full requests", async () => {
const workerScope = createWorkerScope();
vi.stubGlobal("self", workerScope);
await import("@/lib/image-pipeline/image-pipeline.worker");
const sourceComposition: RenderSourceComposition = {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 0.5,
overlayX: 32,
overlayY: 16,
overlayWidth: 128,
overlayHeight: 64,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
};
workerScope.onmessage?.({
data: {
kind: "full",
requestId: 41,
payload: {
sourceComposition,
steps: [],
render: {
resolution: "original",
format: "png",
},
},
},
} as MessageEvent<WorkerMessage>);
await vi.waitFor(() => {
expect(bridgeMocks.renderFull).toHaveBeenCalledTimes(1);
});
expect(bridgeMocks.renderFull).toHaveBeenCalledWith(
expect.objectContaining({
sourceComposition,
}),
);
});
});

View File

@@ -355,4 +355,446 @@ describe("loadSourceBitmap", () => {
expect(createImageBitmap).toHaveBeenCalledWith(fakeVideo);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:video-source");
});
it("renders non-square mixer overlays with contain-fit parity instead of stretching", async () => {
const baseBlob = new Blob(["base"]);
const overlayBlob = new Blob(["overlay"]);
const baseBitmap = { width: 100, height: 100 } as ImageBitmap;
const overlayBitmap = { width: 200, height: 100 } as ImageBitmap;
const composedBitmap = { width: 100, height: 100 } as ImageBitmap;
const drawImage = vi.fn();
const context = {
clearRect: vi.fn(),
drawImage,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
globalCompositeOperation: "source-over" as GlobalCompositeOperation,
globalAlpha: 1,
};
const canvas = {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(context),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
if (tagName.toLowerCase() === "canvas") {
return canvas;
}
return nativeCreateElement(tagName);
});
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("base.png")) {
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(baseBlob),
};
}
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(overlayBlob),
};
}),
);
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockImplementation(async (input: unknown) => {
if (input === baseBlob) {
return baseBitmap;
}
if (input === overlayBlob) {
return overlayBitmap;
}
if (input === canvas) {
return composedBitmap;
}
throw new Error("Unexpected createImageBitmap input in mixer contain-fit test.");
}),
);
const { loadRenderSourceBitmap } = await importSubject();
await expect(
loadRenderSourceBitmap({
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 80,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.25,
overlayHeight: 0.5,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
},
}),
).resolves.toBe(composedBitmap);
expect(drawImage).toHaveBeenNthCalledWith(1, baseBitmap, 0, 0, 100, 100);
const overlayDrawArgs = drawImage.mock.calls[1];
expect(overlayDrawArgs?.[0]).toBe(overlayBitmap);
expect(overlayDrawArgs?.[1]).toBe(0);
expect(overlayDrawArgs?.[2]).toBe(0);
expect(overlayDrawArgs?.[3]).toBe(200);
expect(overlayDrawArgs?.[4]).toBe(100);
expect(overlayDrawArgs?.[5]).toBe(10);
expect(overlayDrawArgs?.[6]).toBeCloseTo(38.75, 10);
expect(overlayDrawArgs?.[7]).toBe(25);
expect(overlayDrawArgs?.[8]).toBeCloseTo(12.5, 10);
});
it("applies mixer crop framing by trimming source edges while leaving the displayed frame size untouched", async () => {
const baseBlob = new Blob(["base"]);
const overlayBlob = new Blob(["overlay"]);
const baseBitmap = { width: 100, height: 100 } as ImageBitmap;
const overlayBitmap = { width: 200, height: 100 } as ImageBitmap;
const composedBitmap = { width: 100, height: 100 } as ImageBitmap;
const drawImage = vi.fn();
const save = vi.fn();
const restore = vi.fn();
const beginPath = vi.fn();
const rect = vi.fn();
const clip = vi.fn();
const context = {
clearRect: vi.fn(),
drawImage,
save,
restore,
beginPath,
rect,
clip,
globalCompositeOperation: "source-over" as GlobalCompositeOperation,
globalAlpha: 1,
};
const canvas = {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(context),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
if (tagName.toLowerCase() === "canvas") {
return canvas;
}
return nativeCreateElement(tagName);
});
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("base.png")) {
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(baseBlob),
};
}
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(overlayBlob),
};
}),
);
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockImplementation(async (input: unknown) => {
if (input === baseBlob) {
return baseBitmap;
}
if (input === overlayBlob) {
return overlayBitmap;
}
if (input === canvas) {
return composedBitmap;
}
throw new Error("Unexpected createImageBitmap input in mixer content framing test.");
}),
);
const { loadRenderSourceBitmap } = await importSubject();
await expect(
loadRenderSourceBitmap({
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 80,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.4,
overlayHeight: 0.4,
cropLeft: 0.5,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
},
}),
).resolves.toBe(composedBitmap);
expect(drawImage).toHaveBeenNthCalledWith(1, baseBitmap, 0, 0, 100, 100);
expect(save).toHaveBeenCalledTimes(1);
expect(beginPath).toHaveBeenCalledTimes(1);
expect(rect).toHaveBeenCalledWith(10, 20, 40, 40);
expect(clip).toHaveBeenCalledTimes(1);
expect(drawImage).toHaveBeenNthCalledWith(
2,
overlayBitmap,
100,
0,
100,
100,
10,
20,
40,
40,
);
expect(restore).toHaveBeenCalledTimes(1);
});
it("keeps overlayWidth and overlayHeight fixed while crop framing trims the sampled source region", async () => {
const baseBlob = new Blob(["base"]);
const overlayBlob = new Blob(["overlay"]);
const baseBitmap = { width: 100, height: 100 } as ImageBitmap;
const overlayBitmap = { width: 200, height: 100 } as ImageBitmap;
const composedBitmap = { width: 100, height: 100 } as ImageBitmap;
const drawImage = vi.fn();
const context = {
clearRect: vi.fn(),
drawImage,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
globalCompositeOperation: "source-over" as GlobalCompositeOperation,
globalAlpha: 1,
};
const canvas = {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(context),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
if (tagName.toLowerCase() === "canvas") {
return canvas;
}
return nativeCreateElement(tagName);
});
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("base.png")) {
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(baseBlob),
};
}
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(overlayBlob),
};
}),
);
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockImplementation(async (input: unknown) => {
if (input === baseBlob) {
return baseBitmap;
}
if (input === overlayBlob) {
return overlayBitmap;
}
if (input === canvas) {
return composedBitmap;
}
throw new Error("Unexpected createImageBitmap input in overlay size preservation test.");
}),
);
const { loadRenderSourceBitmap } = await importSubject();
await expect(
loadRenderSourceBitmap({
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 80,
overlayX: 0.15,
overlayY: 0.25,
overlayWidth: 0.5,
overlayHeight: 0.3,
cropLeft: 0.25,
cropTop: 0.1,
cropRight: 0.25,
cropBottom: 0.3,
},
}),
).resolves.toBe(composedBitmap);
const overlayDrawArgs = drawImage.mock.calls[1];
expect(overlayDrawArgs?.[0]).toBe(overlayBitmap);
expect(overlayDrawArgs?.[1]).toBe(50);
expect(overlayDrawArgs?.[2]).toBe(10);
expect(overlayDrawArgs?.[3]).toBe(100);
expect(overlayDrawArgs?.[4]).toBeCloseTo(60, 10);
expect(overlayDrawArgs?.[5]).toBeCloseTo(15, 10);
expect(overlayDrawArgs?.[6]).toBeCloseTo(25, 10);
expect(overlayDrawArgs?.[7]).toBeCloseTo(50, 10);
expect(overlayDrawArgs?.[8]).toBeCloseTo(30, 10);
});
it("contains a cropped wide source within the overlay frame during bake", async () => {
const baseBlob = new Blob(["base"]);
const overlayBlob = new Blob(["overlay"]);
const baseBitmap = { width: 100, height: 100 } as ImageBitmap;
const overlayBitmap = { width: 200, height: 100 } as ImageBitmap;
const composedBitmap = { width: 100, height: 100 } as ImageBitmap;
const drawImage = vi.fn();
const context = {
clearRect: vi.fn(),
drawImage,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
globalCompositeOperation: "source-over" as GlobalCompositeOperation,
globalAlpha: 1,
};
const canvas = {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(context),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
if (tagName.toLowerCase() === "canvas") {
return canvas;
}
return nativeCreateElement(tagName);
});
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("base.png")) {
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(baseBlob),
};
}
return {
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue("image/png") },
blob: vi.fn().mockResolvedValue(overlayBlob),
};
}),
);
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockImplementation(async (input: unknown) => {
if (input === baseBlob) {
return baseBitmap;
}
if (input === overlayBlob) {
return overlayBitmap;
}
if (input === canvas) {
return composedBitmap;
}
throw new Error("Unexpected createImageBitmap input in aspect-aware crop bake test.");
}),
);
const { loadRenderSourceBitmap } = await importSubject();
await expect(
loadRenderSourceBitmap({
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 80,
overlayX: 0.1,
overlayY: 0.2,
overlayWidth: 0.4,
overlayHeight: 0.4,
cropLeft: 0,
cropTop: 0.25,
cropRight: 0,
cropBottom: 0.25,
},
}),
).resolves.toBe(composedBitmap);
const overlayDrawArgs = drawImage.mock.calls[1];
expect(overlayDrawArgs?.[0]).toBe(overlayBitmap);
expect(overlayDrawArgs?.[1]).toBe(0);
expect(overlayDrawArgs?.[2]).toBe(25);
expect(overlayDrawArgs?.[3]).toBe(200);
expect(overlayDrawArgs?.[4]).toBe(50);
expect(overlayDrawArgs?.[5]).toBe(10);
expect(overlayDrawArgs?.[6]).toBeCloseTo(35, 10);
expect(overlayDrawArgs?.[7]).toBe(40);
expect(overlayDrawArgs?.[8]).toBeCloseTo(10, 10);
});
});

View File

@@ -341,6 +341,7 @@ describe("webgl backend poc", () => {
vi.doMock("@/lib/image-pipeline/source-loader", () => ({
loadSourceBitmap: vi.fn().mockResolvedValue({ width: 2, height: 2 }),
loadRenderSourceBitmap: vi.fn().mockResolvedValue({ width: 2, height: 2 }),
}));
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({

View File

@@ -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(
[
{
@@ -25,7 +25,18 @@ describe("resolveMixerPreviewFromGraph", () => {
{
id: "mixer-1",
type: "mixer",
data: { blendMode: "screen", opacity: 70, offsetX: 12, offsetY: -8 },
data: {
blendMode: "screen",
opacity: 70,
overlayX: 0.12,
overlayY: 0.2,
overlayWidth: 0.6,
overlayHeight: 0.5,
cropLeft: 0.08,
cropTop: 0.15,
cropRight: 0.22,
cropBottom: 0.1,
},
},
],
[
@@ -41,12 +52,114 @@ describe("resolveMixerPreviewFromGraph", () => {
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "screen",
opacity: 70,
offsetX: 12,
offsetY: -8,
overlayX: 0.12,
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(
[
{
@@ -82,11 +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,
offsetX: 0,
offsetY: 0,
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,
});
});
@@ -113,12 +294,18 @@ describe("resolveMixerPreviewFromGraph", () => {
overlayUrl: undefined,
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
});
});
it("normalizes blend mode and clamps numeric values", () => {
it("normalizes crop trims and clamps", () => {
const graph = buildGraphSnapshot(
[
{
@@ -137,8 +324,14 @@ describe("resolveMixerPreviewFromGraph", () => {
data: {
blendMode: "unknown",
opacity: 180,
offsetX: 9999,
offsetY: "-9999",
overlayX: -3,
overlayY: "1.4",
overlayWidth: 2,
overlayHeight: 0,
cropLeft: "0.95",
cropTop: -2,
cropRight: "4",
cropBottom: "0",
},
},
],
@@ -154,8 +347,151 @@ describe("resolveMixerPreviewFromGraph", () => {
overlayUrl: "https://cdn.example.com/overlay-asset.png",
blendMode: "normal",
opacity: 100,
offsetX: 2048,
offsetY: -2048,
overlayX: 0,
overlayY: 0.9,
overlayWidth: 1,
overlayHeight: 0.1,
cropLeft: 0.9,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
});
});
it("missing rect fields fallback to sensible defaults", () => {
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: {
blendMode: "multiply",
opacity: 42,
},
},
],
[
{ 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: "multiply",
opacity: 42,
overlayX: 0,
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,
});
});
it("legacy offset fields still yield visible overlay geometry", () => {
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: {
offsetX: 100,
offsetY: -40,
},
},
],
[
{ 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,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
});
});
@@ -190,8 +526,14 @@ describe("resolveMixerPreviewFromGraph", () => {
overlayUrl: undefined,
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
error: "duplicate-handle-edge",
});
});

View File

@@ -4,8 +4,147 @@ 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", () => {
const graph = buildGraphSnapshot(
[
{
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: "overlay",
opacity: 76,
overlayX: 0.2,
overlayY: 0.1,
overlayWidth: 0.55,
overlayHeight: 0.44,
cropLeft: 0.08,
cropTop: 0.15,
cropRight: 0.22,
cropBottom: 0.1,
},
},
{
id: "render-1",
type: "render",
data: {},
},
],
[
{ source: "base-image", target: "mixer-1", targetHandle: "base" },
{ source: "overlay-image", target: "mixer-1", targetHandle: "overlay" },
{ source: "mixer-1", target: "render-1" },
],
);
const preview = resolveRenderPreviewInputFromGraph({
nodeId: "render-1",
graph,
});
expect(preview).toEqual({
sourceUrl: null,
sourceComposition: {
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "overlay",
opacity: 76,
overlayX: 0.2,
overlayY: 0.1,
overlayWidth: 0.55,
overlayHeight: 0.44,
cropLeft: 0.08,
cropTop: 0.15,
cropRight: 0.22,
cropBottom: 0.1,
},
steps: [],
});
});
it("normalizes mixer composition values for render input", () => {
const graph = buildGraphSnapshot(
[
{
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: "unknown",
opacity: 180,
overlayX: -3,
overlayY: "1.4",
overlayWidth: 2,
overlayHeight: 0,
cropLeft: "0.95",
cropTop: -2,
cropRight: "4",
cropBottom: "0",
},
},
{
id: "render-1",
type: "render",
data: {},
},
],
[
{ source: "base-image", target: "mixer-1", targetHandle: "base" },
{ source: "overlay-image", target: "mixer-1", targetHandle: "overlay" },
{ source: "mixer-1", target: "render-1" },
],
);
const preview = resolveRenderPreviewInputFromGraph({
nodeId: "render-1",
graph,
});
expect(preview.sourceComposition).toEqual({
kind: "mixer",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "normal",
opacity: 100,
overlayX: 0,
overlayY: 0.9,
overlayWidth: 1,
overlayHeight: 0.1,
cropLeft: 0.9,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
});
});
it("includes crop in collected pipeline steps", () => {
const graph = buildGraphSnapshot(
[
@@ -88,5 +227,191 @@ describe("resolveRenderPreviewInputFromGraph", () => {
const preview = resolveRenderPreviewInputFromGraph({ nodeId: "render-1", graph });
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();
});
});

View File

@@ -32,6 +32,13 @@ vi.mock("@/lib/image-pipeline/render-core", () => ({
vi.mock("@/lib/image-pipeline/source-loader", () => ({
loadSourceBitmap: sourceLoaderMocks.loadSourceBitmap,
loadRenderSourceBitmap: ({ sourceUrl }: { sourceUrl?: string }) => {
if (!sourceUrl) {
throw new Error("Render source is required.");
}
return sourceLoaderMocks.loadSourceBitmap(sourceUrl);
},
}));
describe("preview-renderer cancellation", () => {

View File

@@ -199,6 +199,48 @@ describe("worker-client fallbacks", () => {
expect(bridgeMocks.renderFull).not.toHaveBeenCalled();
});
it("does not include AbortSignal in full worker payload serialization", async () => {
const workerMessages: WorkerMessage[] = [];
FakeWorker.behavior = (worker, message) => {
workerMessages.push(message);
if (message.kind !== "full") {
return;
}
queueMicrotask(() => {
worker.onmessage?.({
data: {
kind: "full-result",
requestId: message.requestId,
payload: createFullResult(),
},
} as MessageEvent);
});
};
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
const { renderFullWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
await renderFullWithWorkerFallback({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
render: {
resolution: "original",
format: "png",
},
signal: new AbortController().signal,
});
const fullMessage = workerMessages.find((message) => message.kind === "full") as
| (WorkerMessage & {
payload?: Record<string, unknown>;
})
| undefined;
expect(fullMessage).toBeDefined();
expect(fullMessage?.payload).not.toHaveProperty("signal");
});
it("still falls back to the main thread when the Worker API is unavailable", async () => {
vi.stubGlobal("Worker", undefined);