Merge branch 'feat/mixer-overlay-resize-render-bake'
This commit is contained in:
@@ -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 {
|
||||
|
||||
117
tests/image-pipeline/image-pipeline.worker.test.ts
Normal file
117
tests/image-pipeline/image-pipeline.worker.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user