feat(canvas): separate mixer resize and crop semantics

This commit is contained in:
2026-04-15 08:31:53 +02:00
parent 61728f9e52
commit f1c61fd14e
18 changed files with 4783 additions and 228 deletions

View File

@@ -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);
});
});