feat(canvas): add mixer drag-resize and mixer->render bake

This commit is contained in:
2026-04-11 10:03:41 +02:00
parent ae2fa1d269
commit f499aea691
28 changed files with 1731 additions and 152 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,113 @@
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,
};
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,105 @@ describe("loadSourceBitmap", () => {
expect(createImageBitmap).toHaveBeenCalledWith(fakeVideo);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:video-source");
});
it("renders mixer overlays with object-cover semantics 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,
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 cover-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,
},
}),
).resolves.toBe(composedBitmap);
expect(drawImage).toHaveBeenNthCalledWith(1, baseBitmap, 0, 0, 100, 100);
expect(drawImage).toHaveBeenNthCalledWith(
2,
overlayBitmap,
75,
0,
50,
100,
10,
20,
25,
50,
);
});
});

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({