feat: add crop and canvas resize workflows

This commit is contained in:
syntaxbullet
2026-07-11 12:16:33 +02:00
parent 4d7358af4b
commit 9fb3a19912
20 changed files with 312 additions and 35 deletions

View File

@@ -1,15 +1,17 @@
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
import type { Asset } from "@core/asset";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { Rect } from "@core/geometry";
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): boolean;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
dispose(): void;
@@ -88,7 +90,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
}
}
},
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
render(asset, rect, clipRect, opacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const rotatedRect = rotatedRectBounds(rect, rotation);
@@ -115,7 +117,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(texCoordLocation);
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -123,7 +125,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const contentBounds = rotatedRectBounds(rect, rotation);
@@ -157,12 +159,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedTexCoordLocation);
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -170,7 +172,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
if (clampedOpacity <= 0) return true;
@@ -205,12 +207,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);

View File

@@ -91,17 +91,18 @@ function renderLeafLayer(
if (asset && maskAsset && maskRect && activeMaskTarget) {
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
const contentClipRect = intersectScreenRects(effectiveClipRect, rect);
if (contentClipRect) imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, contentClipRect);
return;
}
}
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation);
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect);
return;
}
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
if (!fallbackRect) return;

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
describe("texture coordinates", () => {
test("maps an asset-pixel crop to normalized UVs", () => {
expect(rounded(textureCoordinatesForCrop({ w: 200, h: 100 }, { x: 50, y: 10, w: 100, h: 40 }))).toEqual([
0.25, 0.1, 0.75, 0.1, 0.25, 0.5, 0.25, 0.5, 0.75, 0.1, 0.75, 0.5,
]);
});
test("maps cropped content onto the corresponding region of an aligned full-size mask", () => {
expect(rounded(textureCoordinatesForRect({ x: 50, y: 20, w: 100, h: 40 }, { x: 0, y: 0, w: 200, h: 100 }))).toEqual([
0.25, 0.2, 0.75, 0.2, 0.25, 0.6, 0.25, 0.6, 0.75, 0.2, 0.75, 0.6,
]);
});
});
function rounded(values: Float32Array) {
return [...values].map((value) => Math.round(value * 1_000) / 1_000);
}

View File

@@ -0,0 +1,17 @@
import type { Rect, Size } from "@core/geometry";
const fullCoordinates = [0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1] as const;
export function textureCoordinatesForCrop(size: Size, crop?: Rect): Float32Array {
if (!crop) return new Float32Array(fullCoordinates);
return textureCoordinatesForRect(crop, { x: 0, y: 0, w: size.w, h: size.h });
}
/** Maps a drawn sub-rectangle onto the texture represented by sourceRect. */
export function textureCoordinatesForRect(drawRect: Rect, sourceRect: Rect): Float32Array {
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}