feat(canvas): separate mixer resize and crop semantics
This commit is contained in:
@@ -83,6 +83,10 @@ describe("image-pipeline.worker full render", () => {
|
||||
overlayY: 16,
|
||||
overlayWidth: 128,
|
||||
overlayHeight: 64,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
};
|
||||
|
||||
workerScope.onmessage?.({
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("loadSourceBitmap", () => {
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:video-source");
|
||||
});
|
||||
|
||||
it("renders mixer overlays with object-cover semantics instead of stretching", async () => {
|
||||
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;
|
||||
@@ -367,6 +367,11 @@ describe("loadSourceBitmap", () => {
|
||||
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,
|
||||
};
|
||||
@@ -420,7 +425,7 @@ describe("loadSourceBitmap", () => {
|
||||
return composedBitmap;
|
||||
}
|
||||
|
||||
throw new Error("Unexpected createImageBitmap input in mixer cover-fit test.");
|
||||
throw new Error("Unexpected createImageBitmap input in mixer contain-fit test.");
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -438,22 +443,358 @@ describe("loadSourceBitmap", () => {
|
||||
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,
|
||||
75,
|
||||
100,
|
||||
0,
|
||||
50,
|
||||
100,
|
||||
100,
|
||||
10,
|
||||
20,
|
||||
25,
|
||||
50,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user