Pada aplikasi web image generation dan editor seperti Boogu-Image, fitur inpainting membutuhkan canvas interaktif untuk menggambar masking biner sebelum dikirim ke pipeline model inferensi. Masalah umum terjadi saat editor canvas ini dirender menggunakan kerangka kerja Server-Side Rendering (SSR) seperti Next.js atau Nuxt: hydration mismatch dan race condition sinkronisasi buffer mask antara DOM server dan canvas client-side.
Akar Masalah Hydration pada Canvas Masking
Elemen HTML5 <canvas> bersifat imperative dan berbasis pixel buffer pada memori browser. Server tidak memiliki akses ke konteks rendering 2D/WebGL, objek window, maupun pointer events perangkat.
Hydration error dan race condition umumnya dipicu oleh tiga faktor teknis:
- Non-deterministic Element Attributes: Dimensi canvas (
widthdanheight) yang dihitung berbasiswindow.innerWidthatau resolusi native gambar menimbulkan perbedaan struktur atribut HTML antara SSR dan client DOM. - DOM Tree Mismatch: Pemuatan dynamic layer untuk stroke, path, atau cursor masking memicu modifikasi DOM sebelum proses rekonsiliasi hidrasi kerangka kerja selesai.
- State vs Buffer Disconnect: Server hanya dapat merender representasi metadata (misal: JSON path atau koordinat), sedangkan canvas membutuhkan decoding base64 atau byte stream
Uint8ClampedArrayuntuk merestorasiImageData.
Strategi Arsitektur: Isolasi Two-Pass Rendering
Solusi yang benar untuk mencegah mismatch hidrasi adalah memisahkan siklus rendering ke dalam dua fase:
- Pass 1 (SSR & Initial Mount): Render representasi statis atau placeholder kontainer dengan dimensi aspek rasio terprediksi tanpa menginisialisasi konteks 2D canvas.
- Pass 2 (Client Hydrated): Pasang elemen
<canvas>, pasang event listener interaksi pointer, dan sinkronkan buffer masking dari cache/state.
Implementasi Kode: Komponen Masking Canvas
Contoh implementasi React/Next.js berikut menunjukkan isolasi hidrasi menggunakan guard useEffect, fallback kontainer terukur, dan sinkronisasi mask buffer aman tanpa race condition.
import React, { useEffect, useRef, useState, useCallback } from 'react';
interface MaskCanvasProps {
baseImageUrl: string;
maskDataUrl?: string;
width: number;
height: number;
onMaskChange: (maskBase64: string) => void;
}
export const MaskCanvasEditor: React.FC<MaskCanvasProps> = ({
baseImageUrl,
maskDataUrl,
width,
height,
onMaskChange,
}) => {
const [isMounted, setIsMounted] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const isDrawing = useRef(false);
// Pass 1: Pastikan canvas imperative hanya aktif di client setelah hidrasi selesai
useEffect(() => {
setIsMounted(true);
}, []);
// Pass 2: Sinkronisasi buffer masking setelah mount
const syncMaskBuffer = useCallback((targetCanvas: HTMLCanvasElement, sourceUrl?: string) => {
const ctx = targetCanvas.getContext('2d');
if (!ctx) return;
if (!sourceUrl) {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, targetCanvas.width, targetCanvas.height);
return;
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
ctx.drawImage(img, 0, 0, targetCanvas.width, targetCanvas.height);
};
img.src = sourceUrl;
}, []);
useEffect(() => {
if (isMounted && canvasRef.current) {
syncMaskBuffer(canvasRef.current, maskDataUrl);
}
}, [isMounted, maskDataUrl, syncMaskBuffer]);
const startDrawing = (e: React.PointerEvent<HTMLCanvasElement>) => {
isDrawing.current = true;
draw(e);
};
const stopDrawing = () => {
if (!isDrawing.current) return;
isDrawing.current = false;
if (canvasRef.current) {
onMaskChange(canvasRef.current.toDataURL('image/png'));
}
};
const draw = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.fillStyle = 'white'; // Mask putih untuk area inpainting
ctx.beginPath();
ctx.arc(x, y, 16, 0, Math.PI * 2);
ctx.fill();
};
// SSR Fallback Placeholder: Menjaga stabilitas layout shifts (CLS) tanpa canvas mismatch
if (!isMounted) {
return (
<div
style={{ width, height, backgroundColor: '#111' }}
aria-label="Memuat editor mask..."
className="canvas-placeholder"
/>
);
}
return (
<div style={{ position: 'relative', width, height }}>
<img
src={baseImageUrl}
alt="Base Reference"
width={width}
height={height}
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
/>
<canvas
ref={canvasRef}
width={width}
height={height}
onPointerDown={startDrawing}
onPointerUp={stopDrawing}
onPointerLeave={stopDrawing}
onPointerMove={draw}
style={{
position: 'absolute',
top: 0,
left: 0,
opacity: 0.6,
cursor: 'crosshair',
touchAction: 'none',
}}
/>
</div>
);
};
Menangani Race Condition pada Dynamic Import
Jika menggunakan lazy loading (misal: next/dynamic dengan { ssr: false }), perhatikan siklus loading gambar referensi versus loading mask editor. Race condition terjadi saat:
- Script modul canvas selesai diunduh lebih lambat daripada respons API data masking.
- State masking terupdate ketika elemen canvas DOM belum selesai dikaitkan ke referensi internal (
ref.current === null).
Solusi: Pisahkan state serialisasi (koordinat vektor atau array aksi stroke) dari layer visual. Simpan data mask sebagai state primitif di React, dan gunakan ResizeObserver atau callback refs untuk memicu transfer buffer hanya saat target node valid.
Debugging Tips
- Cek Mutasi Atribut: Buka console browser. Hydration mismatch pada atribut
styleatau dimensi numerik sering terjadi akibat konversi desimal piksel dari server ke client. Pastikan dimensiwidthdanheightdi-cast menggunakan integer baku (Math.floor). - Hindari CSS Media Queries untuk Ukuran Canvas: Mengubah resolusi canvas via CSS memicu peregangan bitmap. Atur properti atribut
widthdanheightelemen canvas secara eksplisit sesuai rasio aspek target inferensi AI (misal: 512x512, 1024x1024).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!