/**
* OffscreenCanvas 매니저
* HTML Canvas 요소에서 OffscreenCanvas를 생성하고 워커를 통해 관리합니다.
*/
import { EventEmitter } from "eventemitter3";
import {
CanvasCommand,
CanvasCommandType,
CanvasContextType,
CanvasEventType,
CanvasInitCommand,
CanvasResizeCommand,
OffscreenCanvasManagerOptions,
WorkerMessage,
WorkerMessageType,
} from "./types.js";
import { TimeoutManager } from "../core/timeout-manager.js";
/**
* OffscreenCanvas 매니저 클래스
* HTML Canvas 요소의 OffscreenCanvas 버전을 생성하고 워커와 통신합니다.
*/
export class OffscreenCanvasManager extends EventEmitter {
/** 원본 캔버스 요소 */
private canvas: HTMLCanvasElement | null = null;
/** 워커 인스턴스 */
private worker: Worker | null = null;
/** 매니저 옵션 */
private options: OffscreenCanvasManagerOptions;
/** 오프스크린 캔버스가 전송되었는지 여부 */
private canvasTransferred = false;
/** 워커 준비 완료 상태 */
private workerReady = false;
/** 명령 ID 카운터 */
private commandIdCounter = 0;
/** 대기 중인 명령 */
private pendingCommands = new Map<
string,
{
resolve: (value: any) => void;
reject: (reason?: any) => void;
}
>();
/** 애니메이션 프레임 ID */
private animationFrameId: number | null = null;
/** 애니메이션 콜백 */
private animationCallback:
| ((timestamp: number) => CanvasCommand | null)
| null = null;
/** 마지막 애니메이션 타임스탬프 */
private lastAnimationTimestamp = 0;
/** 애니메이션 시작 시간 */
private animationStartTime = 0;
/** 리사이즈 옵저버 */
private resizeObserver: ResizeObserver | null = null;
/** 폴백 모드 사용 여부 */
private useFallbackMode = false;
/** 2D 컨텍스트 (폴백 모드용) */
private fallbackContext: CanvasRenderingContext2D | null = null;
private timeoutManager: TimeoutManager;
/**
* OffscreenCanvasManager 생성자
* @param options 캔버스 매니저 옵션
*/
constructor(options: OffscreenCanvasManagerOptions) {
super();
this.options = {
contextType: CanvasContextType.CONTEXT_2D,
workerCount: 1,
autoResize: true,
debug: false,
...options,
};
// TimeoutManager 초기화
this.timeoutManager = new TimeoutManager({
maxRetries: 2,
retryDelayBase: 1000,
maxJitter: 200,
maxBackoffDelay: 5000,
debug: this.options.debug,
});
this.initialize().catch((error) => {
this.error("초기화 오류:", error);
});
}
/**
* 매니저 초기화
* @private
*/
private async initialize(): Promise {
try {
// 캔버스 요소 가져오기
await this.setupCanvas();
// OffscreenCanvas 지원 확인
if (!this.isOffscreenCanvasSupported()) {
this.log(
"OffscreenCanvas API가 지원되지 않습니다. 폴백 모드로 전환합니다."
);
this.useFallbackMode = true;
this.setupFallbackContext();
this.emit("ready");
return;
}
// 워커 생성
await this.setupWorker();
// 자동 리사이즈 설정
if (this.options.autoResize && this.canvas) {
this.setupResizeObserver();
}
this.log("OffscreenCanvasManager 초기화 완료");
this.emit("ready");
} catch (error) {
this.error("초기화 실패:", error);
this.emit("error", error);
// 오류 발생 시 폴백 모드로 전환 시도 (useFallback 옵션이 true인 경우에만)
if (this.options.useFallback === true) {
try {
this.useFallbackMode = true;
this.setupFallbackContext();
this.log("오류로 인해 폴백 모드로 전환되었습니다.");
this.emit("ready");
} catch (fallbackError) {
this.error("폴백 모드 설정 실패:", fallbackError);
this.emit("error", fallbackError);
}
}
}
}
/**
* 브라우저가 OffscreenCanvas API를 지원하는지 확인
* @private
* @returns API 지원 여부
*/
private isOffscreenCanvasSupported(): boolean {
// 브라우저 환경 확인
if (typeof window === "undefined" || typeof document === "undefined") {
return false;
}
// Canvas 요소 생성 및 API 확인
const canvas = document.createElement("canvas");
return (
typeof canvas.transferControlToOffscreen === "function" &&
typeof window.OffscreenCanvas !== "undefined"
);
}
/**
* 폴백 컨텍스트 설정
* @private
*/
private setupFallbackContext(): void {
if (!this.canvas) {
throw new Error("캔버스가 초기화되지 않았습니다.");
}
// 2D 컨텍스트만 지원
if (this.options.contextType !== CanvasContextType.CONTEXT_2D) {
this.log(
"폴백 모드에서는 2D 컨텍스트만 지원됩니다. 컨텍스트 타입을 2D로 변경합니다."
);
this.options.contextType = CanvasContextType.CONTEXT_2D;
}
// 컨텍스트 생성 - 명시적 타입 캐스팅 사용
const ctx = this.canvas.getContext(
"2d",
this.options.contextAttributes
) as CanvasRenderingContext2D;
if (!ctx) {
throw new Error("캔버스 2D 컨텍스트를 생성할 수 없습니다.");
}
this.fallbackContext = ctx;
this.log("폴백 렌더링 컨텍스트가 설정되었습니다.");
}
/**
* 기본 워커 URL 생성
* @private
* @returns 기본 워커 URL
*/
private getDefaultWorkerUrl(): string {
// 기본 워커 스크립트 내용
const workerScript = `
// OffscreenCanvas 워커 스크립트
let canvas = null;
let ctx = null;
let contextType = null;
let renderId = 0;
// 메시지 처리
self.onmessage = function(event) {
const message = event.data;
try {
if (message.type === 'command') {
const command = message.data;
// 명령 처리
const result = processCommand(command);
// 결과 반환
self.postMessage({
type: 'response',
id: message.id,
data: {
commandId: command.id,
success: true,
data: result
}
});
}
} catch (error) {
self.postMessage({
type: 'response',
id: message.id,
data: {
commandId: message.data.id,
success: false,
error: error.message
}
});
}
};
// 명령 처리 함수
function processCommand(command) {
switch (command.type) {
case 'init':
return initCanvas(command.params);
case 'resize':
return resizeCanvas(command.params);
case 'clear':
return clearCanvas();
case 'render':
return render(command.params);
case 'dispose':
return disposeCanvas();
default:
throw new Error(\`지원되지 않는 명령: \${command.type}\`);
}
}
// 캔버스 초기화
function initCanvas(params) {
if (!canvas) {
throw new Error('OffscreenCanvas가 전송되지 않았습니다.');
}
contextType = params.contextType;
// 컨텍스트 생성
ctx = canvas.getContext(contextType, params.contextAttributes);
if (!ctx) {
throw new Error(\`컨텍스트를 생성할 수 없습니다: \${contextType}\`);
}
return { width: canvas.width, height: canvas.height };
}
// 캔버스 크기 조정
function resizeCanvas(params) {
if (!canvas) return;
canvas.width = params.width;
canvas.height = params.height;
// 2D 컨텍스트 재설정
if (contextType === '2d' && ctx) {
// 컨텍스트 상태 재설정
}
return { width: canvas.width, height: canvas.height };
}
// 캔버스 지우기
function clearCanvas() {
if (!ctx) return;
if (contextType === '2d') {
ctx.clearRect(0, 0, canvas.width, canvas.height);
} else {
ctx.clear(ctx.COLOR_BUFFER_BIT | ctx.DEPTH_BUFFER_BIT);
}
return true;
}
// 렌더링
function render(params) {
if (!ctx) return;
renderId++;
// 각 컨텍스트 유형에 맞게 렌더링
if (contextType === '2d') {
render2D(params);
} else {
renderWebGL(params);
}
self.postMessage({
type: 'event',
data: {
type: 'renderComplete',
renderId
}
});
return { renderId };
}
// 2D 렌더링
function render2D(params) {
// 2D 렌더링 코드
}
// WebGL 렌더링
function renderWebGL(params) {
// WebGL 렌더링 코드
}
// 캔버스 정리
function disposeCanvas() {
// 리소스 정리
ctx = null;
canvas = null;
return true;
}
// 워커 준비 완료 메시지 전송
self.postMessage({
type: 'ready'
});
`;
// Blob URL 생성
const blob = new Blob([workerScript], { type: "application/javascript" });
return URL.createObjectURL(blob);
}
/**
* 캔버스 요소 설정
* @private
*/
private async setupCanvas(): Promise {
// 캔버스 선택자 또는 직접 요소
if (typeof this.options.canvas === "string") {
const element = document.querySelector(this.options.canvas);
if (!element || !(element instanceof HTMLCanvasElement)) {
throw new Error(
`선택자 "${this.options.canvas}"로 유효한 캔버스 요소를 찾을 수 없습니다.`
);
}
this.canvas = element;
} else if (this.options.canvas instanceof HTMLCanvasElement) {
this.canvas = this.options.canvas;
} else {
throw new Error("유효한 캔버스 요소나 선택자가 필요합니다.");
}
}
/**
* 워커 설정
* @private
*/
private async setupWorker(): Promise {
try {
const workerUrl = this.options.workerUrl || this.getDefaultWorkerUrl();
this.log(`워커 초기화 중: ${workerUrl}`);
this.worker = new Worker(
workerUrl,
this.options.workerOptions || { type: "module" }
);
// 메시지 핸들러 설정
this.worker.onmessage = this.handleWorkerMessage.bind(this);
this.worker.onerror = this.handleWorkerError.bind(this);
// 워커 준비 대기
await new Promise((resolve, reject) => {
// TimeoutManager를 사용하여 타임아웃 설정
const timeoutId = this.timeoutManager.set(
"worker-init",
() => {
reject(new Error("워커 초기화 타임아웃"));
},
5000
);
const readyHandler = (event: MessageEvent) => {
const message = event.data as WorkerMessage;
if (message.type === WorkerMessageType.READY) {
this.worker?.removeEventListener("message", readyHandler);
this.timeoutManager.clear("worker-init");
this.workerReady = true;
resolve();
}
};
this.worker?.addEventListener("message", readyHandler);
});
// 캔버스 전송 및 초기화
await this.transferCanvasToWorker();
} catch (error) {
this.error("워커 초기화 실패:", error);
throw error;
}
}
/**
* 캔버스를 워커로 전송
* @private
*/
private async transferCanvasToWorker(): Promise {
if (!this.canvas || !this.worker || this.canvasTransferred) {
return;
}
try {
// OffscreenCanvas API 지원 확인
if (!("transferControlToOffscreen" in this.canvas)) {
throw new Error("OffscreenCanvas API가 지원되지 않습니다.");
}
// OffscreenCanvas 생성
let offscreen;
try {
offscreen = this.canvas.transferControlToOffscreen();
} catch (error) {
throw new Error(`OffscreenCanvas 생성 실패: ${error}`);
}
this.canvasTransferred = true;
// 워커에 캔버스 전송 (drawer-worker.ts 방식과 동일하게)
return new Promise((resolve, reject) => {
// TimeoutManager를 사용하여 초기화 타임아웃 설정
const timeoutId = this.timeoutManager.set(
"canvas-init",
() => {
reject(new Error("캔버스 초기화 타임아웃"));
},
5000
);
// 초기화 완료 이벤트 핸들러
const initHandler = (event: MessageEvent) => {
const message = event.data;
if (message.type === "initialized") {
if (this.worker) {
this.worker.removeEventListener("message", initHandler);
}
this.timeoutManager.clear("canvas-init");
this.log("캔버스가 워커로 전송되고 초기화되었습니다.");
resolve();
} else if (message.type === "error") {
if (this.worker) {
this.worker.removeEventListener("message", initHandler);
}
this.timeoutManager.clear("canvas-init");
reject(new Error(message.data?.message || "캔버스 초기화 실패"));
}
};
if (this.worker) {
this.worker.addEventListener("message", initHandler);
// init 메시지 전송 (drawer-worker.ts 방식과 유사하게)
this.worker.postMessage(
{
type: "init",
canvas: offscreen,
contextType: this.options.contextType,
contextAttributes: this.options.contextAttributes,
width: this.canvas ? this.canvas.width : 300,
height: this.canvas ? this.canvas.height : 150,
devicePixelRatio: window.devicePixelRatio || 1,
},
[offscreen]
);
} else {
reject(new Error("워커 인스턴스가 없습니다."));
}
});
} catch (error) {
this.error("캔버스 전송 실패:", error);
throw error;
}
}
/**
* 명령 ID 생성
* @private
* @returns 고유 명령 ID
*/
private generateCommandId(): string {
return `cmd-${Date.now()}-${this.commandIdCounter++}`;
}
/**
* 명령을 워커에 전송
* @param command 캔버스 명령
* @param transferables 전송 가능한 객체 배열 (옵션)
* @returns 명령 실행 결과
*/
public async sendCommand(
command: CanvasCommand,
transferables: Transferable[] = []
): Promise {
// 폴백 모드에서는 직접 처리
if (this.useFallbackMode) {
return this.executeFallbackCommand(command);
}
if (!this.worker || !this.workerReady) {
throw new Error("워커가 준비되지 않았습니다.");
}
const commandId = command.id || this.generateCommandId();
command.id = commandId;
// Transferable 객체 자동 감지 및 추가
const autoDetectedTransferables = this.detectTransferables(command);
if (autoDetectedTransferables.length > 0) {
// 중복 제거 (이미 명시적으로 전달된 객체는 다시 추가하지 않음)
for (const transferable of autoDetectedTransferables) {
if (!transferables.includes(transferable)) {
transferables.push(transferable);
}
}
}
return new Promise((resolve, reject) => {
const messageId = `msg-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
// 명령 응답 대기
this.pendingCommands.set(messageId, { resolve, reject });
// 워커에 명령 전송
const message: WorkerMessage = {
type: WorkerMessageType.COMMAND,
id: messageId,
data: command,
};
// 디버그 모드에서는 전송되는 Transferables 로깅
if (this.options.debug && transferables.length > 0) {
this.log(
`Transferable 객체 ${
transferables.length
}개 전송: ${this.getTransferableTypeSummary(transferables)}`
);
}
this.worker!.postMessage(message, transferables);
});
}
/**
* 명령에서 Transferable 객체 자동 감지
* @private
* @param command 캔버스 명령
* @returns 감지된 Transferable 객체 배열
*/
private detectTransferables(command: CanvasCommand): Transferable[] {
const transferables: Transferable[] = [];
// 명령 타입에 따라 다른 처리
if (command.type === CanvasCommandType.RENDER && command.params) {
this.findTransferablesInObject(command.params, transferables);
}
return transferables;
}
/**
* 객체 내에서 Transferable 객체 찾기 (재귀적)
* @private
* @param obj 검사할 객체
* @param transferables 찾은 Transferable 객체를 추가할 배열
*/
private findTransferablesInObject(
obj: any,
transferables: Transferable[]
): void {
if (!obj || typeof obj !== "object") return;
// ArrayBuffer 및 TypedArray 감지
if (obj instanceof ArrayBuffer) {
transferables.push(obj);
} else if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) {
// TypedArray(Float32Array, Uint8Array 등)인 경우 기본 버퍼 전송
if (obj.buffer instanceof ArrayBuffer) {
transferables.push(obj.buffer);
}
}
// ImageBitmap 감지
else if (typeof ImageBitmap !== "undefined" && obj instanceof ImageBitmap) {
transferables.push(obj);
}
// OffscreenCanvas 감지
else if (
typeof OffscreenCanvas !== "undefined" &&
obj instanceof OffscreenCanvas
) {
transferables.push(obj);
}
// 배열 처리
else if (Array.isArray(obj)) {
for (const item of obj) {
this.findTransferablesInObject(item, transferables);
}
}
// 객체 처리 (배열이 아닌 객체)
else {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
this.findTransferablesInObject(obj[key], transferables);
}
}
}
}
/**
* Transferable 객체 타입 요약 생성
* @private
* @param transferables Transferable 객체 배열
* @returns 타입 요약 문자열
*/
private getTransferableTypeSummary(transferables: Transferable[]): string {
const typeCounts: Record = {};
for (const item of transferables) {
let type = "unknown";
if (item instanceof ArrayBuffer) {
type = "ArrayBuffer";
} else if (
typeof ImageBitmap !== "undefined" &&
item instanceof ImageBitmap
) {
type = "ImageBitmap";
} else if (
typeof OffscreenCanvas !== "undefined" &&
item instanceof OffscreenCanvas
) {
type = "OffscreenCanvas";
} else if (ArrayBuffer.isView(item) && !(item instanceof DataView)) {
type = item.constructor.name;
}
typeCounts[type] = (typeCounts[type] || 0) + 1;
}
return Object.entries(typeCounts)
.map(([type, count]) => `${type}(${count})`)
.join(", ");
}
/**
* 폴백 모드에서 명령 실행
* @private
* @param command 캔버스 명령
* @returns 명령 실행 결과
*/
private async executeFallbackCommand(
command: CanvasCommand
): Promise {
if (!this.canvas || !this.fallbackContext) {
throw new Error("폴백 컨텍스트가 초기화되지 않았습니다.");
}
const ctx = this.fallbackContext;
const startTime = performance.now();
try {
let result: any = null;
// 명령 타입에 따라 처리
switch (command.type) {
case CanvasCommandType.CLEAR:
// 화면 지우기
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
result = true;
break;
case CanvasCommandType.RESIZE:
// 크기 조정
const resizeCmd = command as CanvasResizeCommand;
const width = resizeCmd.params.width;
const height = resizeCmd.params.height;
this.canvas.width = width;
this.canvas.height = height;
// 리사이즈 이벤트 발생
this.emit(CanvasEventType.RESIZE, {
width,
height,
});
result = { width, height };
break;
case CanvasCommandType.RENDER:
// 렌더링 - 실제 렌더링 로직은 구현이 필요함
// 여기서는 간단히 처리
result = { renderId: Date.now() };
// 렌더링 완료 이벤트
this.emit(CanvasEventType.RENDER_COMPLETE, {
renderId: result.renderId,
time: performance.now() - startTime,
});
break;
default:
this.log(`폴백 모드에서 지원하지 않는 명령: ${command.type}`);
break;
}
return result as T;
} catch (error) {
this.error("폴백 명령 실행 실패:", error);
throw error;
}
}
/**
* 워커 메시지 핸들러
* @private
* @param event 메시지 이벤트
*/
private handleWorkerMessage(event: MessageEvent): void {
const message = event.data as WorkerMessage;
if (!message || !message.type) {
return;
}
switch (message.type) {
case WorkerMessageType.RESPONSE:
case "response":
this.handleCommandResponse(message);
break;
case WorkerMessageType.EVENT:
case "event":
this.handleWorkerEvent(message);
break;
case WorkerMessageType.ERROR:
case "error":
this.error("워커 오류:", message.data);
this.emit("error", message.data);
break;
}
}
/**
* 명령 응답 처리
* @private
* @param message 워커 메시지
*/
private handleCommandResponse(message: WorkerMessage): void {
const pendingCommand = this.pendingCommands.get(message.id!);
if (!pendingCommand) {
return;
}
this.pendingCommands.delete(message.id!);
const { success, data, error } = message.data;
if (success) {
pendingCommand.resolve(data);
} else {
pendingCommand.reject(new Error(error || "명령 실행 실패"));
}
}
/**
* 워커 이벤트 처리
* @private
* @param message 워커 메시지
*/
private handleWorkerEvent(message: WorkerMessage): void {
const event = message.data;
if (!event || !event.type) {
return;
}
// 이벤트 전달
this.emit(event.type, event);
}
/**
* 워커 오류 핸들러
* @private
* @param error 오류 이벤트
*/
private handleWorkerError(error: ErrorEvent): void {
this.error("워커 오류:", error);
this.emit("error", error);
}
/**
* 리사이즈 옵저버 설정
* @private
*/
private setupResizeObserver(): void {
if (!this.canvas || typeof ResizeObserver === "undefined") {
return;
}
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === this.canvas) {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
this.resize(width, height).catch((err) => {
this.error("리사이즈 실패:", err);
});
}
}
});
this.resizeObserver.observe(this.canvas);
}
/**
* 캔버스 크기 변경
* @param width 새 너비 (픽셀)
* @param height 새 높이 (픽셀)
* @returns 크기 변경 결과
*/
public async resize(width: number, height: number): Promise {
if (!this.worker || !this.workerReady || !this.canvasTransferred) {
throw new Error("워커가 준비되지 않았습니다.");
}
const dpr = window.devicePixelRatio || 1;
// 크기 변경 명령 생성
const resizeCommand: CanvasResizeCommand = {
id: this.generateCommandId(),
type: CanvasCommandType.RESIZE,
params: {
width: Math.floor(width * dpr),
height: Math.floor(height * dpr),
devicePixelRatio: dpr,
},
};
// 워커에 명령 전송
return this.sendCommand(resizeCommand);
}
/**
* 캔버스 지우기
* @returns 지우기 결과
*/
public async clear(): Promise {
return this.sendCommand({
type: CanvasCommandType.CLEAR,
});
}
/**
* 렌더링 명령 전송
* @param params 렌더링 매개변수
* @returns 렌더링 결과
*/
public async render(params?: any): Promise {
return this.sendCommand({
type: CanvasCommandType.RENDER,
params,
});
}
/**
* 애니메이션 시작
* @param callback 애니메이션 프레임마다 호출될 콜백 함수
*/
public startAnimation(
callback: (timestamp: number) => CanvasCommand | null
): void {
if (this.animationFrameId !== null) {
this.stopAnimation();
}
this.animationCallback = callback;
this.animationStartTime = performance.now();
this.lastAnimationTimestamp = this.animationStartTime;
this.animationLoop(this.animationStartTime);
}
/**
* 애니메이션 루프
* @private
* @param timestamp 현재 타임스탬프
*/
private animationLoop(timestamp: number): void {
this.animationFrameId = requestAnimationFrame(
this.animationLoop.bind(this)
);
const deltaTime = timestamp - this.lastAnimationTimestamp;
this.lastAnimationTimestamp = timestamp;
if (!this.animationCallback) {
return;
}
// 콜백 실행하여 다음 명령 가져오기
const command = this.animationCallback(timestamp - this.animationStartTime);
if (command) {
// 비동기로 명령 전송 (응답 대기 없음)
this.sendCommand(command).catch((err) => {
this.error("애니메이션 명령 실패:", err);
});
}
}
/**
* 애니메이션 중지
*/
public stopAnimation(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.animationCallback = null;
}
/**
* 객체 정리
*/
public dispose(): void {
// 애니메이션 중지
this.stopAnimation();
// 정리
this.timeoutManager.clearAll();
// 워커 정리
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
// 리사이즈 옵저버 정리
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// 보류 중인 명령 정리
for (const pendingCmd of this.pendingCommands.values()) {
pendingCmd.reject(new Error("OffscreenCanvasManager가 정리되었습니다."));
}
this.pendingCommands.clear();
// 상태 초기화
this.canvas = null;
this.canvasTransferred = false;
this.workerReady = false;
this.fallbackContext = null;
this.log("OffscreenCanvasManager 정리 완료");
}
/**
* 디버그 로그 출력
* @private
* @param message 로그 메시지
* @param args 추가 매개변수
*/
private log(message: string, ...args: any[]): void {
if (this.options.debug) {
console.log(`[OffscreenCanvasManager] ${message}`, ...args);
}
}
/**
* 오류 로그 출력
* @private
* @param message 오류 메시지
* @param args 추가 매개변수
*/
private error(message: string, ...args: any[]): void {
console.error(`[OffscreenCanvasManager] ${message}`, ...args);
}
}