Draft
Story Canvas Editor 仕様
Story Canvas Editor のレンダリング仕様。キャンバス座標系、要素の描画順序、
テキストレンダリング、エクスポート処理を厳密に定義します。
1. キャンバス仕様
1.1 キャンバスサイズ
| 幅 |
1080px |
| 高さ |
1920px |
| アスペクト比 |
9:16(縦型) |
1.2 座標系
- 原点: 左上 (0, 0)
- X軸: 右方向が正(0 〜 1080)
- Y軸: 下方向が正(0 〜 1920)
- 単位: ピクセル(px)
エディタ表示時は、実際のキャンバスサイズと表示サイズの比率(canvasScale)を計算し、座標変換を行います。
canvasScale = displayWidth / CANVAS_WIDTH
displayX = canvasX * canvasScale
displayY = canvasY * canvasScale
2. 要素型定義
2.1 基底型 CanvasElement
すべてのキャンバス要素が持つ共通プロパティ。
| プロパティ |
型 |
説明 |
id |
string |
一意の識別子(UUID形式推奨) |
type |
'image' | 'text' |
要素の種別 |
x |
number |
左上X座標(0-1080) |
y |
number |
左上Y座標(0-1920) |
width |
number |
幅(px)、最小値: 50 |
height |
number |
高さ(px)、最小値: 50 |
rotation |
number |
回転角度(度)、現在未実装 |
zIndex |
number |
描画順序(大きいほど前面) |
2.2 ImageElement
画像要素。CanvasElement を拡張。
| プロパティ |
型 |
説明 |
type |
'image' |
固定値 |
r2_key |
string |
R2ストレージのキー |
content_type |
string |
MIMEタイプ(image/jpeg, image/png等) |
preview |
string |
プレビュー用Blob URL |
dominantColors |
string[] |
抽出した主要色(RGB形式) |
2.3 TextElement
テキスト要素。CanvasElement を拡張。
| プロパティ |
型 |
説明 |
type |
'text' |
固定値 |
content |
string |
テキスト内容 |
fontSize |
number |
フォントサイズ(px)、範囲: 24-96、デフォルト: 48 |
fontWeight |
'normal' | 'bold' |
フォントウェイト、デフォルト: 'bold' |
color |
string |
テキスト色(CSS color形式)、デフォルト: '#ffffff' |
backgroundColor |
string? |
背景色(CSS color形式)、デフォルト: 'rgba(0,0,0,0.5)' |
3. 背景仕様
3.1 背景タイプ
- 単色: CSS color値(例:
#000000, rgb(0,0,0))
- グラデーション: CSS linear-gradient(例:
linear-gradient(135deg, #667eea 0%, #764ba2 100%))
3.2 プリセット背景
| ID |
名前 |
値 |
black |
黒 |
#000000 |
white |
白 |
#ffffff |
gray |
グレー |
#374151 |
blue |
青 |
linear-gradient(135deg, #667eea 0%, #764ba2 100%) |
sunset |
夕焼け |
linear-gradient(135deg, #fa709a 0%, #fee140 100%) |
ocean |
海 |
linear-gradient(135deg, #667eea 0%, #00d4ff 100%) |
forest |
森 |
linear-gradient(135deg, #11998e 0%, #38ef7d 100%) |
night |
夜 |
linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%) |
pink |
ピンク |
linear-gradient(135deg, #f093fb 0%, #f5576c 100%) |
3.3 背景レンダリング
function drawBackground(ctx: CanvasRenderingContext2D, background: string): void {
if (background.startsWith('linear-gradient')) {
// 135度の対角グラデーション(左上→右下)
const gradient = ctx.createLinearGradient(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// CSS linear-gradientから色を抽出
const colors = background.match(/#[a-fA-F0-9]{6}/g) || [];
const stops = background.match(/(\d+)%/g)?.map(s => parseInt(s) / 100) || [];
colors.forEach((color, i) => {
const stop = stops[i] ?? (i / (colors.length - 1));
gradient.addColorStop(stop, color);
});
ctx.fillStyle = gradient;
} else {
ctx.fillStyle = background;
}
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
4. 描画順序
4.1 レイヤー構造
- 背景レイヤー: 最背面(zIndex関係なく常に最初に描画)
- 要素レイヤー:
zIndex の昇順で描画(小さい値が背面)
4.2 描画アルゴリズム
function render(ctx: CanvasRenderingContext2D, state: EditorState): void {
// 1. キャンバスをクリア
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// 2. 背景を描画
drawBackground(ctx, state.background);
// 3. 要素をzIndex順にソート
const sortedElements = [...state.elements].sort((a, b) => a.zIndex - b.zIndex);
// 4. 各要素を順番に描画
for (const element of sortedElements) {
if (element.type === 'image') {
drawImageElement(ctx, element);
} else if (element.type === 'text') {
drawTextElement(ctx, element);
}
}
}
5. 画像要素レンダリング
5.1 描画仕様
- スケーリング: 要素の width/height に合わせて拡縮
- 配置: 要素の (x, y) を左上として配置
- アスペクト比: 維持しない(要素サイズに合わせて変形)
5.2 描画アルゴリズム
async function drawImageElement(
ctx: CanvasRenderingContext2D,
element: ImageElement
): Promise<void> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
ctx.drawImage(
img,
element.x, // 描画先X
element.y, // 描画先Y
element.width, // 描画幅
element.height // 描画高さ
);
resolve();
};
img.onerror = () => {
console.error('Failed to load image:', element.preview);
resolve();
};
img.src = element.preview;
});
}
6. テキスト要素レンダリング
6.1 描画仕様
| フォントファミリー |
sans-serif |
| テキスト配置(水平) |
center(要素の中央) |
| テキスト配置(垂直) |
middle(要素の中央) |
| 行の高さ |
fontSize × 1.2 |
| テキスト折り返し |
要素幅 - 20px(左右10pxパディング) |
6.2 テキスト折り返しアルゴリズム
日本語テキストは文字単位で折り返し(単語分割なし)。
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number
): string[] {
const lines: string[] = [];
let currentLine = '';
// 文字単位でループ(日本語対応)
for (const char of text) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine !== '') {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine !== '') {
lines.push(currentLine);
}
return lines;
}
6.3 描画アルゴリズム
function drawTextElement(
ctx: CanvasRenderingContext2D,
element: TextElement
): void {
const PADDING = 10;
const LINE_HEIGHT_RATIO = 1.2;
// 1. 背景を描画(設定されている場合)
if (element.backgroundColor) {
ctx.fillStyle = element.backgroundColor;
ctx.fillRect(element.x, element.y, element.width, element.height);
}
// 2. フォント設定
ctx.fillStyle = element.color;
ctx.font = `${element.fontWeight} ${element.fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 3. テキスト折り返し
const maxWidth = element.width - (PADDING * 2);
const lines = wrapText(ctx, element.content, maxWidth);
// 4. 行の高さと開始Y座標を計算
const lineHeight = element.fontSize * LINE_HEIGHT_RATIO;
const totalHeight = lines.length * lineHeight;
const startY = element.y + (element.height - totalHeight) / 2 + lineHeight / 2;
// 5. 各行を描画
const centerX = element.x + element.width / 2;
lines.forEach((line, index) => {
ctx.fillText(line, centerX, startY + index * lineHeight);
});
}
7. 色抽出アルゴリズム
7.1 概要
画像から主要な色を抽出し、背景色パレットとして提案。
7.2 アルゴリズム
async function extractDominantColors(imageUrl: string): Promise<string[]> {
const SAMPLE_SIZE = 50; // サンプリング解像度
const QUANTIZE_STEP = 32; // 色の量子化ステップ(256/32 = 8段階)
const MAX_COLORS = 5; // 返す色の最大数
// 1. 画像を読み込み
const img = await loadImage(imageUrl);
// 2. 小さいキャンバスにリサンプリング
const canvas = document.createElement('canvas');
canvas.width = SAMPLE_SIZE;
canvas.height = SAMPLE_SIZE;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
// 3. ピクセルデータを取得
const imageData = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data;
const colorCounts: Map<string, number> = new Map();
// 4. 各ピクセルを量子化してカウント
for (let i = 0; i < imageData.length; i += 4) {
const alpha = imageData[i + 3];
if (alpha < 128) continue; // 半透明以下はスキップ
const r = Math.round(imageData[i] / QUANTIZE_STEP) * QUANTIZE_STEP;
const g = Math.round(imageData[i + 1] / QUANTIZE_STEP) * QUANTIZE_STEP;
const b = Math.round(imageData[i + 2] / QUANTIZE_STEP) * QUANTIZE_STEP;
const key = `rgb(${r},${g},${b})`;
colorCounts.set(key, (colorCounts.get(key) || 0) + 1);
}
// 5. 頻度でソートして上位を返す
return [...colorCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_COLORS)
.map(([color]) => color);
}
8. 表示時間の自動計算
8.1 計算式
function calculateDuration(elements: CanvasElement[]): string {
let seconds = 3; // 基本時間
const textElements = elements.filter(e => e.type === 'text');
const imageElements = elements.filter(e => e.type === 'image');
// テキスト要素1つにつき +2秒
seconds += textElements.length * 2;
// テキストの長さに応じて追加(20文字ごとに +1秒)
for (const el of textElements) {
seconds += Math.ceil(el.content.length / 20);
}
// 画像が2枚以上の場合 +2秒
if (imageElements.length >= 2) {
seconds += 2;
}
// 範囲制限: 3-15秒
seconds = Math.max(3, Math.min(15, seconds));
return `PT${seconds}S`; // ISO 8601 duration形式
}
8.2 例
| 要素構成 |
計算 |
結果 |
| 画像1枚のみ |
3 |
PT3S |
| 画像1枚 + テキスト(10文字) |
3 + 2 + 1 = 6 |
PT6S |
| 画像2枚 + テキスト(30文字) |
3 + 2 + 2 + 2 = 9 |
PT9S |
| テキスト2つ(各50文字) |
3 + 4 + 6 = 13 |
PT13S |
9. エクスポート仕様
9.1 出力フォーマット
| 形式 |
JPEG |
| 品質 |
0.9(90%) |
| サイズ |
1080 × 1920 px |
| ファイル名 |
story.jpg |
9.2 エクスポート処理
async function exportCanvas(state: EditorState): Promise<Blob> {
// 1. オフスクリーンキャンバスを作成
const canvas = document.createElement('canvas');
canvas.width = CANVAS_WIDTH; // 1080
canvas.height = CANVAS_HEIGHT; // 1920
const ctx = canvas.getContext('2d');
// 2. 全要素を描画
await render(ctx, state);
// 3. JPEGとしてエクスポート
return new Promise((resolve) => {
canvas.toBlob(
(blob) => resolve(blob),
'image/jpeg',
0.9 // 品質90%
);
});
}
10. エディタUI仕様
10.1 選択状態の表示
- 選択枠: 2px solid #3B82F6(blue-500)
- リサイズハンドル: 四隅に16×16pxの円形ハンドル
- ハンドルスタイル: 白背景、2px solid #3B82F6の枠
10.2 要素の初期配置
| 要素 |
初期位置 |
初期サイズ |
| 画像 |
(140, 560) - キャンバス中央 |
800 × 800 px |
| テキスト |
(240, 860) - キャンバス中央 |
600 × 200 px |
10.3 制約
- 最小サイズ: 50 × 50 px
- フォントサイズ範囲: 24 〜 96 px
- フォントサイズ増減ステップ: 8 px