Skip to main content

ブラウザ合成 → R2直PUT システム設計

Next.js管理画面・ブラウザサイドCanvas合成システム
Web Worker + OffscreenCanvas による高品質画像合成とR2直接アップロード

システム概要

基本設計思想

  • ブラウザサイド合成: サーバー負荷ゼロ、Canvas/Worker活用
  • R2直PUT: 署名URL利用、中間サーバー経由なし
  • 非同期処理: UI固まらず、進捗表示付き
  • バッチ処理: 16バリエーションの順次処理

アーキテクチャフロー

graph TD
A[管理画面] --> B[署名URL一括取得]
B --> C[Web Worker起動]
C --> D[OffscreenCanvas合成]
D --> E[JPEG Blob生成]
E --> F[R2直PUT]
F --> G[マニフェスト登録]
G --> H[完了通知]

実装タスク詳細

【API-001】署名URLまとめ発行 API

エンドポイント設計

// POST /api/r2/sign
interface SignedUrlRequest {
files: Array<{
key: string; // R2オブジェクトキー
contentType: string; // 'image/jpeg'
}>;
}

interface SignedUrlResponse {
urls: Array<{
key: string;
signedUrl: string; // PUT用署名URL
expiresIn: number; // 有効期限(秒)
}>;
requestId: string; // リクエスト追跡用
}

実装仕様

  • ファイル: apps/web/src/app/api/r2/sign/route.ts
  • 機能: Cloudflare Workers BFF への署名URL発行リクエストプロキシ
  • バリデーション: ファイル数上限(最大25件)、キー形式チェック
  • セキュリティ: Firebase認証必須、管理者権限チェック

R2キー命名規約

display_data/{artwork_id}/premium_frame/{frame_color}_{mat_color}_web.jpg
display_data/{artwork_id}/wooden_frame/{frame_color}_web.jpg
display_data/{artwork_id}/paper_stand/{color}_web.jpg
display_data/{artwork_id}/postcard/front_web.jpg
display_data/{artwork_id}/postcard/back_web.jpg

例:
display_data/artwork_001/premium_frame/natural_white_web.jpg
display_data/artwork_001/wooden_frame/brown_web.jpg
display_data/artwork_001/paper_stand/navy_web.jpg

【IMG-001】Web Worker 画像合成

Worker仕様詳細

// public/workers/composer.js
class ImageComposer {
constructor() {
this.canvas = new OffscreenCanvas(3555, 2528);
this.ctx = this.canvas.getContext('2d');
this.imageCache = new Map(); // 画像キャッシュ
}

async compose(request) {
const { artworkUrl, frameUrl, matUrl, backgroundColor, productType, orientation } = request;

try {
// Canvas初期化
this.ctx.clearRect(0, 0, 3555, 2528);

// 合成順序(重要)
await this.drawBackground(backgroundColor);

if (matUrl && productType === 'premium_frame') {
await this.drawMat(matUrl);
}

await this.drawArtwork(artworkUrl, orientation);

if (frameUrl) {
await this.drawFrame(frameUrl, productType);
}

// JPEG変換・品質調整
return await this.canvas.convertToBlob('image/jpeg', 0.88);

} catch (error) {
throw new CompositionError('CANVAS_ERROR', error.message);
}
}

async drawBackground(color = '#FFFFFF') {
this.ctx.fillStyle = color;
this.ctx.fillRect(0, 0, 3555, 2528);
}

async drawMat(matUrl) {
const matImage = await this.loadImageWithCache(matUrl);
this.ctx.drawImage(matImage, 0, 0, 3555, 2528);
}

async drawArtwork(artworkUrl, orientation) {
const artworkImage = await this.loadImageWithCache(artworkUrl);

// アスペクト比維持・中央寄せ計算
const bounds = this.calculateArtworkBounds(artworkImage, orientation);

// 高品質リサイズ
this.ctx.imageSmoothingEnabled = true;
this.ctx.imageSmoothingQuality = 'high';

this.ctx.drawImage(
artworkImage,
bounds.x, bounds.y,
bounds.width, bounds.height
);
}

async drawFrame(frameUrl, productType) {
const frameImage = await this.loadImageWithCache(frameUrl);

// フレームタイプ別調整
const frameConfig = FRAME_CONFIGS[productType];

this.ctx.drawImage(
frameImage,
frameConfig.x, frameConfig.y,
frameConfig.width, frameConfig.height
);
}

calculateArtworkBounds(image, orientation) {
const canvasW = 3555, canvasH = 2528;
const imgW = image.naturalWidth, imgH = image.naturalHeight;

// フレーム内エリア計算(フレームタイプ別)
const contentArea = {
premium_frame: { x: 200, y: 150, w: 3155, h: 2228 },
wooden_frame: { x: 100, y: 75, w: 3355, h: 2378 },
paper_stand: { x: 50, y: 50, w: 3455, h: 2428 },
postcard: { x: 20, y: 20, w: 3515, h: 2488 }
};

const area = contentArea[this.productType] || contentArea.premium_frame;

// アスペクト比維持リサイズ
const scaleW = area.w / imgW;
const scaleH = area.h / imgH;
const scale = Math.min(scaleW, scaleH);

const scaledW = imgW * scale;
const scaledH = imgH * scale;

return {
x: area.x + (area.w - scaledW) / 2,
y: area.y + (area.h - scaledH) / 2,
width: scaledW,
height: scaledH
};
}

async loadImageWithCache(url) {
if (this.imageCache.has(url)) {
return this.imageCache.get(url);
}

return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';

img.onload = () => {
this.imageCache.set(url, img);
resolve(img);
};

img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
img.src = url;
});
}
}

// フレーム配置設定
const FRAME_CONFIGS = {
premium_frame: { x: 0, y: 0, width: 3555, height: 2528 },
wooden_frame: { x: 0, y: 0, width: 3555, height: 2528 },
paper_stand: { x: 0, y: 0, width: 3555, height: 2528 },
postcard: { x: 0, y: 0, width: 3555, height: 2528 }
};

// Worker メッセージハンドラー
self.onmessage = async function(e) {
const { id, type, data } = e.data;

try {
if (type === 'compose') {
const composer = new ImageComposer();
const blob = await composer.compose(data);

self.postMessage({
id,
type: 'success',
blob: blob,
processingTime: Date.now() - data.startTime
});

} else if (type === 'preload') {
// 画像プリロード
const composer = new ImageComposer();
await Promise.all(data.urls.map(url => composer.loadImageWithCache(url)));

self.postMessage({
id,
type: 'preload_complete',
cachedCount: data.urls.length
});
}

} catch (error) {
self.postMessage({
id,
type: 'error',
error: {
type: error.constructor.name,
message: error.message,
retryable: error.retryable !== false
}
});
}
};

合成処理詳細

interface CompositionRequest {
artworkUrl: string; // 作品画像URL
frameType: 'premium_frame' | 'wooden_frame' | 'paper_stand' | 'postcard';
frameColor?: string; // 額縁色
matColor?: string; // マット色(premium_frameのみ)
backgroundColor: string; // 背景色(通常白)
outputQuality: number; // JPEG品質 (0.85-0.9)
}

interface CompositionResponse {
success: boolean;
blob?: Blob; // JPEG画像データ
error?: string;
processingTime: number; // 処理時間(ms)
}

画像素材管理

public/templates/
├── frames/
│ ├── premium/
│ │ ├── natural.png
│ │ ├── silver.png
│ │ ├── brown.png
│ │ └── black.png
│ ├── wooden/
│ │ ├── natural.png
│ │ ├── brown.png
│ │ └── black.png
│ └── paper_stand/
│ ├── navy.png
│ ├── black.png
│ └── white.png
├── mats/
│ ├── white.png
│ ├── natural.png
│ ├── indigo.png
│ └── black.png
└── postcard/
└── template.png

【ADM-001】管理画面 順次合成&PUT

ページ構成

// apps/web/src/app/admin/compose/page.tsx
interface ComposePageState {
artworkId: string;
artworkImage: File | null;
compositionPlan: CompositionPlan[];
progress: {
total: number;
completed: number;
failed: number;
current: string;
};
results: CompositionResult[];
}

interface CompositionPlan {
id: string;
productType: string;
variantKey: string;
frameColor?: string;
matColor?: string;
r2Key: string;
}

処理フロー詳細

class CompositionBatchProcessor {
private worker: Worker;
private progressCallbacks: Map<string, Function>;

constructor() {
this.worker = new Worker('/workers/composer.js');
this.progressCallbacks = new Map();
this.setupWorkerHandlers();
}

async executeCompositionBatch(plans: CompositionPlan[]) {
const startTime = Date.now();

try {
// Phase 1: 準備・バリデーション
await this.validatePlans(plans);
this.updateGlobalProgress('preparing', 0, plans.length);

// Phase 2: 署名URL一括取得
const signedUrls = await this.getSignedUrls(plans.map(p => p.r2Key));
this.updateGlobalProgress('urls_ready', 0, plans.length);

// Phase 3: 画像素材プリロード
await this.preloadAssets(plans);
this.updateGlobalProgress('assets_ready', 0, plans.length);

// Phase 4: 順次合成・PUT処理(並列度3)
const results = await this.processConcurrentBatch(plans, signedUrls, 3);

// Phase 5: 結果検証
const successCount = results.filter(r => r.status === 'success').length;
if (successCount === 0) {
throw new Error('All compositions failed');
}

// Phase 6: マニフェスト一括登録
await this.registerManifest({
artwork_id: plans[0].artworkId,
variants: results
.filter(r => r.status === 'success')
.map(r => ({
key: r.r2Key,
type: r.productType,
frame: r.frameColor,
mat: r.matColor,
file_size: r.fileSize,
generated_at: new Date().toISOString()
}))
});

this.updateGlobalProgress('completed', successCount, plans.length);

return {
total: plans.length,
success: successCount,
failed: plans.length - successCount,
duration: Date.now() - startTime,
results
};

} catch (error) {
this.updateGlobalProgress('error', 0, plans.length, error);
throw error;
}
}

async processConcurrentBatch(plans: CompositionPlan[], signedUrls: Record<string, string>, concurrency: number = 3) {
const results: CompositionResult[] = [];
const queue = [...plans];
const inProgress = new Set<string>();

return new Promise((resolve, reject) => {
const processNext = async () => {
// 並列度制御
if (inProgress.size >= concurrency || queue.length === 0) {
if (inProgress.size === 0 && queue.length === 0) {
resolve(results);
}
return;
}

const plan = queue.shift()!;
inProgress.add(plan.id);

try {
const result = await this.processComposition(plan, signedUrls[plan.r2Key]);
results.push(result);

this.updateProgress(plan.id, result.status === 'success' ? 'completed' : 'failed', result);
this.updateGlobalProgress('processing', results.length, plans.length);

} catch (error) {
results.push({
id: plan.id,
r2Key: plan.r2Key,
status: 'error',
error: error.message,
retryCount: plan.retryCount || 0
});

this.updateProgress(plan.id, 'failed', error);
} finally {
inProgress.delete(plan.id);
processNext(); // 次の処理を開始
}
};

// 初期並列処理開始
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
processNext();
}
});
}

async processComposition(plan: CompositionPlan, signedUrl: string): Promise<CompositionResult> {
const startTime = Date.now();

// Step 1: Worker で合成
this.updateProgress(plan.id, 'composing');

const blob = await this.composeWithWorker({
id: plan.id,
artworkUrl: plan.artworkUrl,
frameUrl: plan.frameUrl,
matUrl: plan.matUrl,
productType: plan.productType,
orientation: plan.orientation,
backgroundColor: '#FFFFFF',
startTime
});

// Step 2: ファイルサイズ・品質チェック
if (blob.size > 5 * 1024 * 1024) { // 5MB制限
throw new Error(`File size too large: ${(blob.size / 1024 / 1024).toFixed(1)}MB`);
}

// Step 3: R2 に直PUT
this.updateProgress(plan.id, 'uploading');

const uploadResult = await this.uploadToR2(signedUrl, blob, {
timeout: 30000,
retries: 2
});

return {
id: plan.id,
r2Key: plan.r2Key,
status: 'success',
fileSize: blob.size,
uploadTime: Date.now() - startTime,
productType: plan.productType,
frameColor: plan.frameColor,
matColor: plan.matColor
};
}

private async composeWithWorker(request: CompositionRequest): Promise<Blob> {
return new Promise((resolve, reject) => {
const messageId = `compose_${Date.now()}_${Math.random()}`;

const timeout = setTimeout(() => {
this.progressCallbacks.delete(messageId);
reject(new Error('Composition timeout'));
}, 30000);

this.progressCallbacks.set(messageId, (response) => {
clearTimeout(timeout);

if (response.type === 'success') {
resolve(response.blob);
} else if (response.type === 'error') {
reject(new Error(response.error.message));
}
});

this.worker.postMessage({
id: messageId,
type: 'compose',
data: request
});
});
}

private async uploadToR2(signedUrl: string, blob: Blob, options: { timeout: number; retries: number }): Promise<void> {
let lastError: Error;

for (let i = 0; i <= options.retries; i++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout);

const response = await fetch(signedUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': 'image/jpeg'
},
signal: controller.signal
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}

return; // 成功

} catch (error) {
lastError = error as Error;

if (i < options.retries) {
// 指数バックオフでリトライ
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
}

throw lastError!;
}

private setupWorkerHandlers() {
this.worker.onmessage = (e) => {
const { id, type, blob, error } = e.data;
const callback = this.progressCallbacks.get(id);

if (callback) {
callback(e.data);
this.progressCallbacks.delete(id);
}
};

this.worker.onerror = (error) => {
console.error('Worker error:', error);
// 全ての待機中のコールバックにエラー通知
this.progressCallbacks.forEach(callback => {
callback({ type: 'error', error: { message: 'Worker crashed' } });
});
this.progressCallbacks.clear();
};
}
}

// 使用例
const processor = new CompositionBatchProcessor();

await processor.executeCompositionBatch([
{
id: 'plan_001',
artworkId: 'artwork_hokusai_wave',
artworkUrl: '/uploads/hokusai_wave.jpg',
productType: 'premium_frame',
frameColor: 'natural',
matColor: 'white',
frameUrl: '/templates/frames/premium/natural.png',
matUrl: '/templates/mats/white.png',
r2Key: 'display_data/artwork_hokusai_wave/premium_frame/natural_white_web.jpg',
orientation: 'landscape'
},
// ... 他の15プラン
]);

UI コンポーネント

// 進捗表示UI
const ProgressPanel = ({ progress, results }) => (
<div className="composition-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${(progress.completed / progress.total) * 100}%` }}
/>
</div>

<div className="stats">
<span>完了: {progress.completed}/{progress.total}</span>
<span>失敗: {progress.failed}</span>
<span>処理中: {progress.current}</span>
</div>

<div className="results-list">
{results.map(result => (
<ResultItem key={result.id} result={result} />
))}
</div>
</div>
);

// リトライ機能付き結果表示
const ResultItem = ({ result }) => (
<div className={`result-item ${result.status}`}>
<span>{result.variantKey}</span>
<span>{result.status}</span>
{result.status === 'failed' && (
<button onClick={() => retryComposition(result.id)}>
リトライ
</button>
)}
</div>
);

【DB-001】マニフェスト登録 API

エンドポイント設計

// POST /api/manifest
interface ManifestRequest {
artwork_id: string;
variants: Array<{
key: string; // R2オブジェクトキー
type: string; // 商品タイプ
frame?: string; // 額縁色
mat?: string; // マット色
file_size?: number; // ファイルサイズ
generated_at: string; // 生成日時
}>;
}

interface ManifestResponse {
success: boolean;
registered_count: number;
manifest_id: string;
}

D1データベース更新

-- バリエーション画像レコード更新
UPDATE tokinoe_variant_images
SET
web_image_key = ?,
generation_status = 'completed',
generated_at = datetime('now'),
file_size_bytes = ?
WHERE
artwork_id = ?
AND variant_key = ?;

-- 作品マスター更新
UPDATE tokinoe_artworks
SET
generation_status = 'completed',
updated_at = datetime('now')
WHERE
id = ?
AND (SELECT COUNT(*) FROM tokinoe_variant_images
WHERE artwork_id = ? AND generation_status = 'completed') = total_variants;

技術仕様詳細

Canvas 合成処理

画像サイズ・品質設定

const COMPOSITION_CONFIG = {
canvas: {
width: 3555,
height: 2528,
dpi: 300 // 印刷品質相当
},
output: {
format: 'image/jpeg',
quality: 0.88, // 品質とファイルサイズのバランス
maxFileSizeMB: 5 // アップロード上限
},
processing: {
timeout: 30000, // 30秒タイムアウト
retryCount: 3 // 失敗時リトライ回数
}
};

合成レイヤー順序

async function compositeImage(ctx: OffscreenCanvasRenderingContext2D, request: CompositionRequest) {
// Layer 1: 背景色塗りつぶし
ctx.fillStyle = request.backgroundColor || '#FFFFFF';
ctx.fillRect(0, 0, 3555, 2528);

// Layer 2: マット配置(premium_frameのみ)
if (request.frameType === 'premium_frame' && request.matColor) {
const matImage = await loadImage(`/templates/mats/${request.matColor}.png`);
ctx.drawImage(matImage, 0, 0, 3555, 2528);
}

// Layer 3: 作品画像配置(中央寄せ・アスペクト比維持)
const artworkImage = await loadImage(request.artworkUrl);
const artworkBounds = calculateArtworkBounds(artworkImage, request.frameType);
ctx.drawImage(
artworkImage,
artworkBounds.x, artworkBounds.y,
artworkBounds.width, artworkBounds.height
);

// Layer 4: 額縁配置(最前面)
if (request.frameColor) {
const frameImage = await loadImage(`/templates/frames/${request.frameType}/${request.frameColor}.png`);
ctx.drawImage(frameImage, 0, 0, 3555, 2528);
}
}

エラーハンドリング

エラー分類と対応

enum CompositionErrorType {
NETWORK_ERROR = 'network_error', // ネットワーク接続エラー
IMAGE_LOAD_ERROR = 'image_load_error', // 画像読み込みエラー
CANVAS_ERROR = 'canvas_error', // Canvas処理エラー
UPLOAD_ERROR = 'upload_error', // R2アップロードエラー
TIMEOUT_ERROR = 'timeout_error' // タイムアウトエラー
}

class CompositionError extends Error {
constructor(
public type: CompositionErrorType,
public message: string,
public retryable: boolean = true
) {
super(message);
}
}

// リトライ戦略
const RETRY_CONFIG = {
[CompositionErrorType.NETWORK_ERROR]: { maxRetries: 3, delay: 2000 },
[CompositionErrorType.IMAGE_LOAD_ERROR]: { maxRetries: 2, delay: 1000 },
[CompositionErrorType.CANVAS_ERROR]: { maxRetries: 1, delay: 500 },
[CompositionErrorType.UPLOAD_ERROR]: { maxRetries: 3, delay: 3000 },
[CompositionErrorType.TIMEOUT_ERROR]: { maxRetries: 2, delay: 5000 }
};

セキュリティ・制限事項

アップロード制限

const SECURITY_CONFIG = {
auth: {
required: true,
roles: ['admin', 'editor'], // 管理者・編集者のみ
session_timeout: 3600 // 1時間
},

upload: {
maxFileSize: 5 * 1024 * 1024, // 5MB
maxBatchSize: 25, // 一度に最大25ファイル
allowedTypes: ['image/jpeg'], // JPEG のみ
rateLimiting: {
requests: 10, // 10リクエスト
windowMs: 60 * 1000 // 1分間
}
},

r2: {
signedUrlExpiry: 1800, // 署名URL有効期限30分
maxObjectSize: 10 * 1024 * 1024, // R2オブジェクト上限10MB
bucketAccess: 'admin-only' // バケットアクセス制限
}
};

運用・監視

パフォーマンス監視

interface PerformanceMetrics {
composition: {
averageTime: number; // 平均合成時間(ms)
successRate: number; // 成功率(%)
errorRate: number; // エラー率(%)
};

upload: {
averageSpeed: number; // 平均アップロード速度(MB/s)
timeoutRate: number; // タイムアウト率(%)
};

batch: {
averageBatchTime: number; // 平均バッチ処理時間(分)
concurrencyLevel: number; // 同時処理数
};
}

// メトリクス収集
function collectMetrics(operation: string, startTime: number, result: 'success' | 'error') {
const duration = Date.now() - startTime;

// CloudWatch Metrics or Analytics への送信
sendMetric(`composition.${operation}.duration`, duration);
sendMetric(`composition.${operation}.result.${result}`, 1);
}

品質管理

interface QualityCheck {
// ファイルサイズチェック
validateFileSize: (blob: Blob) => boolean;

// 画像品質チェック
validateImageQuality: (blob: Blob) => Promise<{
resolution: { width: number; height: number };
fileSize: number;
quality: number;
valid: boolean;
}>;

// 合成結果検証
validateComposition: (originalUrl: string, compositeBlob: Blob) => Promise<{
similarity: number; // 類似度スコア
hasArtwork: boolean; // 作品が含まれているか
hasFrame: boolean; // 額縁が含まれているか
valid: boolean;
}>;
}

受け入れ基準

機能要件

  • 管理画面から16枚のバリエーション画像を順次合成できる
  • Web Worker使用でUI が固まらない
  • 全ての合成画像をR2に直接PUT成功する
  • JPEG品質0.85-0.9、サイズ3555×2528px で出力
  • 失敗時にエラー表示とリトライが可能

非機能要件

  • 1画像あたりの合成時間: 10秒以内
  • 16画像バッチ処理時間: 5分以内
  • メモリ使用量: 500MB以内
  • 同時処理: 最大3画像まで
  • エラー率: 5%以下

セキュリティ要件

  • Firebase認証による管理者権限チェック
  • 署名URL有効期限30分以内
  • アップロードファイルサイズ制限5MB
  • レート制限: 10リクエスト/分

文書作成日: 2025-08-23
最終更新: 2025-08-23
バージョン: 1.0 - ブラウザ合成システム設計版