feat(canvas): add mixer drag-resize and mixer->render bake
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
applyGeometryStepsToSource,
|
||||
partitionPipelineSteps,
|
||||
} from "@/lib/image-pipeline/geometry-transform";
|
||||
import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader";
|
||||
import { loadRenderSourceBitmap } from "@/lib/image-pipeline/source-loader";
|
||||
|
||||
type SupportedCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||
type SupportedContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
@@ -99,7 +99,11 @@ function resolveMimeType(format: RenderFormat): string {
|
||||
export async function renderFull(options: RenderFullOptions): Promise<RenderFullResult> {
|
||||
const { signal } = options;
|
||||
|
||||
const bitmap = await loadSourceBitmap(options.sourceUrl, { signal });
|
||||
const bitmap = await loadRenderSourceBitmap({
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
signal,
|
||||
});
|
||||
const { geometrySteps, tonalSteps } = partitionPipelineSteps(options.steps);
|
||||
const geometryResult = applyGeometryStepsToSource({
|
||||
source: bitmap,
|
||||
|
||||
@@ -2,14 +2,19 @@ import { renderFull } from "@/lib/image-pipeline/bridge";
|
||||
import { renderPreview } from "@/lib/image-pipeline/preview-renderer";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import type { HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
|
||||
import type {
|
||||
RenderFullOptions,
|
||||
RenderFullResult,
|
||||
RenderSourceComposition,
|
||||
} from "@/lib/image-pipeline/render-types";
|
||||
import {
|
||||
IMAGE_PIPELINE_BACKEND_FLAG_KEYS,
|
||||
type BackendFeatureFlags,
|
||||
} from "@/lib/image-pipeline/backend/feature-flags";
|
||||
|
||||
type PreviewWorkerPayload = {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
@@ -112,6 +117,7 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay
|
||||
applyWorkerFeatureFlags(payload.featureFlags);
|
||||
const result = await renderPreview({
|
||||
sourceUrl: payload.sourceUrl,
|
||||
sourceComposition: payload.sourceComposition,
|
||||
steps: payload.steps,
|
||||
previewWidth: payload.previewWidth,
|
||||
includeHistogram: payload.includeHistogram,
|
||||
@@ -161,6 +167,7 @@ async function handleFullRequest(requestId: number, payload: FullWorkerPayload):
|
||||
applyWorkerFeatureFlags(payload.featureFlags);
|
||||
const result = await renderFull({
|
||||
sourceUrl: payload.sourceUrl,
|
||||
sourceComposition: payload.sourceComposition,
|
||||
steps: payload.steps,
|
||||
render: payload.render,
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
applyGeometryStepsToSource,
|
||||
partitionPipelineSteps,
|
||||
} from "@/lib/image-pipeline/geometry-transform";
|
||||
import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader";
|
||||
import { loadRenderSourceBitmap } from "@/lib/image-pipeline/source-loader";
|
||||
import type { RenderSourceComposition } from "@/lib/image-pipeline/render-types";
|
||||
|
||||
export type PreviewRenderResult = {
|
||||
width: number;
|
||||
@@ -64,13 +65,16 @@ async function yieldToMainOrWorkerLoop(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function renderPreview(options: {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewRenderResult> {
|
||||
const bitmap = await loadSourceBitmap(options.sourceUrl, {
|
||||
const bitmap = await loadRenderSourceBitmap({
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
signal: options.signal,
|
||||
});
|
||||
const { geometrySteps, tonalSteps } = partitionPipelineSteps(options.steps);
|
||||
|
||||
@@ -24,6 +24,18 @@ export type RenderSizeLimits = {
|
||||
maxPixels?: number;
|
||||
};
|
||||
|
||||
export type RenderSourceComposition = {
|
||||
kind: "mixer";
|
||||
baseUrl: string;
|
||||
overlayUrl: string;
|
||||
blendMode: "normal" | "multiply" | "screen" | "overlay";
|
||||
opacity: number;
|
||||
overlayX: number;
|
||||
overlayY: number;
|
||||
overlayWidth: number;
|
||||
overlayHeight: number;
|
||||
};
|
||||
|
||||
export type ResolvedRenderSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -32,7 +44,8 @@ export type ResolvedRenderSize = {
|
||||
};
|
||||
|
||||
export type RenderFullOptions = {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
render: RenderOptions;
|
||||
limits?: RenderSizeLimits;
|
||||
|
||||
@@ -12,6 +12,24 @@ type LoadSourceBitmapOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type RenderSourceComposition = {
|
||||
kind: "mixer";
|
||||
baseUrl: string;
|
||||
overlayUrl: string;
|
||||
blendMode: "normal" | "multiply" | "screen" | "overlay";
|
||||
opacity: number;
|
||||
overlayX: number;
|
||||
overlayY: number;
|
||||
overlayWidth: number;
|
||||
overlayHeight: number;
|
||||
};
|
||||
|
||||
type LoadRenderSourceBitmapOptions = {
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
@@ -215,3 +233,200 @@ export async function loadSourceBitmap(
|
||||
const promise = getOrCreateSourceBitmapPromise(sourceUrl);
|
||||
return await awaitWithLocalAbort(promise, options.signal);
|
||||
}
|
||||
|
||||
function createWorkingCanvas(width: number, height: number):
|
||||
| HTMLCanvasElement
|
||||
| OffscreenCanvas {
|
||||
if (typeof document !== "undefined") {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(width, height);
|
||||
}
|
||||
|
||||
throw new Error("Canvas rendering is not available in this environment.");
|
||||
}
|
||||
|
||||
function mixerBlendModeToCompositeOperation(
|
||||
blendMode: RenderSourceComposition["blendMode"],
|
||||
): GlobalCompositeOperation {
|
||||
if (blendMode === "normal") {
|
||||
return "source-over";
|
||||
}
|
||||
|
||||
return blendMode;
|
||||
}
|
||||
|
||||
function normalizeCompositionOpacity(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, value)) / 100;
|
||||
}
|
||||
|
||||
function normalizeRatio(value: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMixerRect(source: RenderSourceComposition): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const overlayX = Math.max(0, Math.min(0.9, normalizeRatio(source.overlayX, 0)));
|
||||
const overlayY = Math.max(0, Math.min(0.9, normalizeRatio(source.overlayY, 0)));
|
||||
const overlayWidth = Math.max(
|
||||
0.1,
|
||||
Math.min(1, normalizeRatio(source.overlayWidth, 1), 1 - overlayX),
|
||||
);
|
||||
const overlayHeight = Math.max(
|
||||
0.1,
|
||||
Math.min(1, normalizeRatio(source.overlayHeight, 1), 1 - overlayY),
|
||||
);
|
||||
|
||||
return {
|
||||
x: overlayX,
|
||||
y: overlayY,
|
||||
width: overlayWidth,
|
||||
height: overlayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function computeObjectCoverSourceRect(args: {
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
destinationWidth: number;
|
||||
destinationHeight: number;
|
||||
}): {
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
} {
|
||||
const { sourceWidth, sourceHeight, destinationWidth, destinationHeight } = args;
|
||||
|
||||
if (
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0 ||
|
||||
destinationWidth <= 0 ||
|
||||
destinationHeight <= 0
|
||||
) {
|
||||
return {
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceAspectRatio = sourceWidth / sourceHeight;
|
||||
const destinationAspectRatio = destinationWidth / destinationHeight;
|
||||
|
||||
if (!Number.isFinite(sourceAspectRatio) || !Number.isFinite(destinationAspectRatio)) {
|
||||
return {
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceAspectRatio > destinationAspectRatio) {
|
||||
const croppedWidth = sourceHeight * destinationAspectRatio;
|
||||
return {
|
||||
sourceX: (sourceWidth - croppedWidth) / 2,
|
||||
sourceY: 0,
|
||||
sourceWidth: croppedWidth,
|
||||
sourceHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const croppedHeight = sourceWidth / destinationAspectRatio;
|
||||
return {
|
||||
sourceX: 0,
|
||||
sourceY: (sourceHeight - croppedHeight) / 2,
|
||||
sourceWidth,
|
||||
sourceHeight: croppedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMixerCompositionBitmap(
|
||||
sourceComposition: RenderSourceComposition,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImageBitmap> {
|
||||
const [baseBitmap, overlayBitmap] = await Promise.all([
|
||||
loadSourceBitmap(sourceComposition.baseUrl, { signal }),
|
||||
loadSourceBitmap(sourceComposition.overlayUrl, { signal }),
|
||||
]);
|
||||
|
||||
throwIfAborted(signal);
|
||||
|
||||
const canvas = createWorkingCanvas(baseBitmap.width, baseBitmap.height);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
throw new Error("Render composition could not create a 2D context.");
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, baseBitmap.width, baseBitmap.height);
|
||||
context.drawImage(baseBitmap, 0, 0, baseBitmap.width, baseBitmap.height);
|
||||
|
||||
const rect = normalizeMixerRect(sourceComposition);
|
||||
const destinationX = rect.x * baseBitmap.width;
|
||||
const destinationY = rect.y * baseBitmap.height;
|
||||
const destinationWidth = rect.width * baseBitmap.width;
|
||||
const destinationHeight = rect.height * baseBitmap.height;
|
||||
const sourceRect = computeObjectCoverSourceRect({
|
||||
sourceWidth: overlayBitmap.width,
|
||||
sourceHeight: overlayBitmap.height,
|
||||
destinationWidth,
|
||||
destinationHeight,
|
||||
});
|
||||
|
||||
context.globalCompositeOperation = mixerBlendModeToCompositeOperation(
|
||||
sourceComposition.blendMode,
|
||||
);
|
||||
context.globalAlpha = normalizeCompositionOpacity(sourceComposition.opacity);
|
||||
context.drawImage(
|
||||
overlayBitmap,
|
||||
sourceRect.sourceX,
|
||||
sourceRect.sourceY,
|
||||
sourceRect.sourceWidth,
|
||||
sourceRect.sourceHeight,
|
||||
destinationX,
|
||||
destinationY,
|
||||
destinationWidth,
|
||||
destinationHeight,
|
||||
);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.globalAlpha = 1;
|
||||
|
||||
return await createImageBitmap(canvas);
|
||||
}
|
||||
|
||||
export async function loadRenderSourceBitmap(
|
||||
options: LoadRenderSourceBitmapOptions,
|
||||
): Promise<ImageBitmap> {
|
||||
if (options.sourceComposition) {
|
||||
if (options.sourceComposition.kind !== "mixer") {
|
||||
throw new Error(`Unsupported source composition '${options.sourceComposition.kind}'.`);
|
||||
}
|
||||
|
||||
return await loadMixerCompositionBitmap(options.sourceComposition, options.signal);
|
||||
}
|
||||
|
||||
if (!options.sourceUrl) {
|
||||
throw new Error("Render source is required.");
|
||||
}
|
||||
|
||||
return await loadSourceBitmap(options.sourceUrl, { signal: options.signal });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
} from "@/lib/image-pipeline/preview-renderer";
|
||||
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import type { HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
|
||||
import type {
|
||||
RenderFullOptions,
|
||||
RenderFullResult,
|
||||
RenderSourceComposition,
|
||||
} from "@/lib/image-pipeline/render-types";
|
||||
import {
|
||||
getBackendFeatureFlags,
|
||||
type BackendFeatureFlags,
|
||||
@@ -20,7 +24,8 @@ export type BackendDiagnosticsMetadata = {
|
||||
};
|
||||
|
||||
type PreviewWorkerPayload = {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
@@ -324,13 +329,14 @@ function runWorkerRequest<TResponse extends PreviewRenderResult | RenderFullResu
|
||||
}
|
||||
|
||||
function getPreviewRequestKey(options: {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
hashPipeline(options.sourceUrl, options.steps),
|
||||
hashPipeline(options.sourceComposition ?? options.sourceUrl ?? null, options.steps),
|
||||
options.previewWidth,
|
||||
options.includeHistogram === true ? 1 : 0,
|
||||
].join(":");
|
||||
@@ -341,7 +347,8 @@ function getWorkerFeatureFlagsSnapshot(): BackendFeatureFlags {
|
||||
}
|
||||
|
||||
async function runPreviewRequest(options: {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
@@ -352,6 +359,7 @@ async function runPreviewRequest(options: {
|
||||
kind: "preview",
|
||||
payload: {
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
steps: options.steps,
|
||||
previewWidth: options.previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
@@ -367,6 +375,7 @@ async function runPreviewRequest(options: {
|
||||
if (!shouldFallbackToMainThread(error)) {
|
||||
logWorkerClientDebug("preview request failed without fallback", {
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
previewWidth: options.previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
diagnostics: getLastBackendDiagnostics(),
|
||||
@@ -377,6 +386,7 @@ async function runPreviewRequest(options: {
|
||||
|
||||
logWorkerClientDebug("preview request falling back to main-thread", {
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
previewWidth: options.previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
error,
|
||||
@@ -387,7 +397,8 @@ async function runPreviewRequest(options: {
|
||||
}
|
||||
|
||||
function getOrCreateSharedPreviewRequest(options: {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
@@ -419,7 +430,8 @@ function getOrCreateSharedPreviewRequest(options: {
|
||||
}
|
||||
|
||||
export async function renderPreviewWithWorkerFallback(options: {
|
||||
sourceUrl: string;
|
||||
sourceUrl?: string;
|
||||
sourceComposition?: RenderSourceComposition;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
@@ -431,6 +443,7 @@ export async function renderPreviewWithWorkerFallback(options: {
|
||||
|
||||
const sharedRequest = getOrCreateSharedPreviewRequest({
|
||||
sourceUrl: options.sourceUrl,
|
||||
sourceComposition: options.sourceComposition,
|
||||
steps: options.steps,
|
||||
previewWidth: options.previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
|
||||
Reference in New Issue
Block a user