OpenCut/apps/web/src/services/renderer/effect-preview.ts

195 lines
4.7 KiB
TypeScript

import { createOffscreenCanvas } from "./canvas-utils";
import { getEffect } from "@/lib/effects";
import type { EffectParamValues } from "@/types/effects";
import { applyMultiPassEffect } from "./webgl-utils";
import type { EffectPassData } from "./webgl-utils";
const PREVIEW_SIZE = 160;
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
let previewGl: WebGLRenderingContext | null = null;
let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
let previewImageElement: HTMLImageElement | null = null;
const programCache = new Map<string, WebGLProgram>();
const onReadyCallbacks = new Set<() => void>();
export function onPreviewImageReady({
callback,
}: {
callback: () => void;
}): () => void {
onReadyCallbacks.add(callback);
return () => onReadyCallbacks.delete(callback);
}
function loadPreviewImage(): void {
if (typeof window === "undefined") return;
const image = new Image();
image.onload = () => {
testSourceCanvas = null;
for (const callback of onReadyCallbacks) {
callback();
}
};
image.src = PREVIEW_IMAGE_PATH;
previewImageElement = image;
}
loadPreviewImage();
function buildDefaultParams({
effectType,
}: {
effectType: string;
}): EffectParamValues {
const definition = getEffect({ effectType });
const params: EffectParamValues = {};
for (const paramDef of definition.params) {
params[paramDef.key] = paramDef.default;
}
return params;
}
function createTestSource({
width,
height,
}: {
width: number;
height: number;
}): OffscreenCanvas | HTMLCanvasElement | null {
const isImageReady =
previewImageElement?.complete &&
(previewImageElement.naturalWidth ?? 0) > 0;
if (!isImageReady || !previewImageElement) {
return null;
}
const canvas = createOffscreenCanvas({ width, height });
const ctx = canvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) {
throw new Error("failed to get 2d context for test source");
}
ctx.drawImage(previewImageElement, 0, 0, width, height);
return canvas;
}
function getOrCreatePreviewContext({
width,
height,
}: {
width: number;
height: number;
}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
if (!previewCanvas || !previewGl) {
previewCanvas = createOffscreenCanvas({ width, height });
previewGl = previewCanvas.getContext("webgl", {
premultipliedAlpha: false,
}) as WebGLRenderingContext | null;
if (!previewGl) {
throw new Error("WebGL not supported");
}
}
if (previewCanvas.width !== width || previewCanvas.height !== height) {
previewCanvas.width = width;
previewCanvas.height = height;
}
return { canvas: previewCanvas, gl: previewGl };
}
function getTestSource({
width,
height,
}: {
width: number;
height: number;
}): CanvasImageSource | null {
if (
!testSourceCanvas ||
testSourceCanvas.width !== width ||
testSourceCanvas.height !== height
) {
testSourceCanvas = createTestSource({ width, height });
}
return testSourceCanvas;
}
function applyWebGlEffect({
source,
width,
height,
passes,
}: {
source: CanvasImageSource;
width: number;
height: number;
passes: EffectPassData[];
}): OffscreenCanvas | HTMLCanvasElement {
const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height });
applyMultiPassEffect({ context: gl, source, width, height, passes, programCache });
const outputCanvas = createOffscreenCanvas({ width, height });
const outputCtx = outputCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (outputCtx) {
outputCtx.drawImage(glCanvas, 0, 0, width, height);
}
return outputCanvas;
}
export function renderPreview({
effectType,
params,
targetCanvas,
}: {
effectType: string;
params: EffectParamValues;
targetCanvas: HTMLCanvasElement;
}): void {
const size = PREVIEW_SIZE;
const source = getTestSource({ width: size, height: size });
if (!source) return;
const definition = getEffect({ effectType });
const resolvedParams =
Object.keys(params).length > 0
? params
: buildDefaultParams({ effectType });
const passes = definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
uniforms: pass.uniforms({
effectParams: resolvedParams,
width: size,
height: size,
}),
}));
const result = applyWebGlEffect({
source,
width: size,
height: size,
passes,
});
const targetCtx = targetCanvas.getContext(
"2d",
) as CanvasRenderingContext2D | null;
if (targetCtx) {
targetCanvas.width = size;
targetCanvas.height = size;
targetCtx.drawImage(result, 0, 0, size, size);
}
}
export const effectPreviewService = {
renderPreview,
onPreviewImageReady,
PREVIEW_SIZE,
};