Enables users to upload custom avatar assets and automatically remove the background from the generated image. New features: - Avatar creation now supports uploading base, blink, and talk textures. - Added ability to define the main body bounding box during rigging. - Vision service now includes image segmentation for background removal. - Studio component dynamically processes the avatar image for background removal if chroma key is enabled.
95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
import { Rect } from '../types';
|
|
|
|
export const fileToDataUrl = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => resolve(e.target?.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
export const loadImage = (src: string): Promise<HTMLImageElement> => {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.crossOrigin = "anonymous";
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
img.src = src;
|
|
});
|
|
};
|
|
|
|
export const stitchAssets = async (
|
|
base: File,
|
|
blink?: File,
|
|
talk?: File
|
|
): Promise<{ imageUrl: string; mainBody: Rect; textureClosedEye?: Rect; textureOpenMouth?: Rect }> => {
|
|
// Load images
|
|
const baseData = await fileToDataUrl(base);
|
|
const baseImg = await loadImage(baseData);
|
|
|
|
const blinkImg = blink ? await loadImage(await fileToDataUrl(blink)) : null;
|
|
const talkImg = talk ? await loadImage(await fileToDataUrl(talk)) : null;
|
|
|
|
// Layout: Base on Left. Sidebar on Right containing Blink (top) and Talk (bottom).
|
|
// Sidebar width = max(blink.width, talk.width)
|
|
const sidebarWidth = Math.max(blinkImg?.width || 0, talkImg?.width || 0);
|
|
|
|
// If there are no variants, just return the base image as is
|
|
if (sidebarWidth === 0) {
|
|
return {
|
|
imageUrl: baseData,
|
|
mainBody: { x: 0, y: 0, w: 1, h: 1 }
|
|
};
|
|
}
|
|
|
|
const totalWidth = baseImg.width + sidebarWidth;
|
|
const totalHeight = Math.max(baseImg.height, (blinkImg?.height || 0) + (talkImg?.height || 0));
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = totalWidth;
|
|
canvas.height = totalHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error("Could not get canvas context");
|
|
|
|
// Draw Base
|
|
ctx.drawImage(baseImg, 0, 0);
|
|
|
|
// Calculate normalized rects
|
|
const mainBody: Rect = {
|
|
x: 0,
|
|
y: 0,
|
|
w: baseImg.width / totalWidth,
|
|
h: baseImg.height / totalHeight
|
|
};
|
|
|
|
let textureClosedEye: Rect | undefined;
|
|
if (blinkImg) {
|
|
ctx.drawImage(blinkImg, baseImg.width, 0);
|
|
textureClosedEye = {
|
|
x: baseImg.width / totalWidth,
|
|
y: 0,
|
|
w: blinkImg.width / totalWidth,
|
|
h: blinkImg.height / totalHeight
|
|
};
|
|
}
|
|
|
|
let textureOpenMouth: Rect | undefined;
|
|
if (talkImg) {
|
|
const yPos = blinkImg ? blinkImg.height : 0;
|
|
ctx.drawImage(talkImg, baseImg.width, yPos);
|
|
textureOpenMouth = {
|
|
x: baseImg.width / totalWidth,
|
|
y: yPos / totalHeight,
|
|
w: talkImg.width / totalWidth,
|
|
h: talkImg.height / totalHeight
|
|
};
|
|
}
|
|
|
|
return {
|
|
imageUrl: canvas.toDataURL('image/png'),
|
|
mainBody,
|
|
textureClosedEye,
|
|
textureOpenMouth
|
|
};
|
|
}; |